Files
QodeAssist/tests/QodeAssistTest.cpp
2026-07-21 13:10:00 +02:00

5437 lines
194 KiB
C++
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/ChatModel.hpp"
#include "ChatView/ChatFileStore.hpp"
#include "FakeAcpAgent.hpp"
#include "FakeLlmProvider.hpp"
#include "ChatView/ChatConfigurationController.hpp"
#include "ChatView/ConversationCoordinator.hpp"
#include "ChatView/FileMentionItem.hpp"
#include "ChatView/LlmChatBackend.hpp"
#include "settings/ChatAssistantSettings.hpp"
#include "session/BlockCodec.hpp"
#include "session/FileEditPayload.hpp"
#include "session/TurnLedger.hpp"
#include "acp/AcpChatBackend.hpp"
#include "acp/AgentBinding.hpp"
#include "acp/AgentKnowledgeService.hpp"
#include "mcp/AgentKnowledgeServer.hpp"
#include "tools/BuildProjectTool.hpp"
#include "tools/CreateNewFileTool.hpp"
#include "tools/EditFileTool.hpp"
#include "tools/ExecuteTerminalCommandTool.hpp"
#include "tools/EditorStateTools.hpp"
#include "tools/GetIssuesListTool.hpp"
#include "tools/ReadFileTool.hpp"
#include <LLMQore/ToolsManager.hpp>
#include "acp/AgentCatalog.hpp"
#include "acp/AgentCatalogStore.hpp"
#include "acp/AgentLaunch.hpp"
#include "acp/AgentRegistryParser.hpp"
#include "acp/AgentSpawn.hpp"
#include <LLMQore/AcpClient.hpp>
#include "completion/CodeHandler.hpp"
#include "completion/LLMSuggestion.hpp"
#include "context/FileEditManager.hpp"
#include "llmcore/ContextData.hpp"
#include "providers/ClaudeCacheControl.hpp"
#include "session/FencedText.hpp"
#include "session/HistoryProjection.hpp"
#include "session/HistorySerializer.hpp"
#include "session/Session.hpp"
#include "session/TurnContextBuilder.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QScopeGuard>
#include <QSignalSpy>
#include <QUuid>
#include <QHostAddress>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSet>
#include <QTemporaryDir>
#include <QTest>
#include <QTextDocument>
#include <QUrl>
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_lastUserBlocks = request.userBlocks;
m_historySizeAtSend = request.history ? request.history->size() : -1;
m_lastContext = request.context;
}
void cancel() override { ++m_cancelCount; }
bool respondPermission(const QString &requestId, const QString &optionId) override
{
m_permissionAnswers.append({requestId, optionId});
if (!m_acceptPermissionAnswers)
return false;
if (m_autoResolvePermissions)
emit sessionEvent(Session::PermissionResolved{m_turnId, requestId, optionId, false});
return true;
}
void script(const QList<Session::SessionEvent> &events)
{
for (const Session::SessionEvent &scripted : events) {
if (const auto *started = std::get_if<Session::TurnStarted>(&scripted))
m_turnId = started->turnId;
emit sessionEvent(scripted);
}
}
void setAutoResolvePermissions(bool automatic) { m_autoResolvePermissions = automatic; }
void setAcceptPermissionAnswers(bool accept) { m_acceptPermissionAnswers = accept; }
QList<QPair<QString, QString>> permissionAnswers() const { return m_permissionAnswers; }
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; }
private:
int m_turnCount = 0;
int m_cancelCount = 0;
bool m_autoResolvePermissions = true;
bool m_acceptPermissionAnswers = true;
qsizetype m_historySizeAtSend = -1;
QString m_turnId;
QList<QPair<QString, QString>> m_permissionAnswers;
QList<Session::ContentBlock> m_lastUserBlocks;
std::optional<Session::TurnContext> m_lastContext;
};
QList<Session::PermissionOption> editPermissionOptions()
{
return {
Session::PermissionOption{"opt-once", "Yes", "allow_once"},
Session::PermissionOption{"opt-always", "Yes, and don't ask again", "allow_always"},
Session::PermissionOption{"opt-no", "No", "reject_once"}};
}
Session::PermissionRequested editPermissionRequest(
const QString &turnId, const QString &requestId, const QString &toolKind = QStringLiteral("edit"))
{
return Session::PermissionRequested{
.turnId = turnId,
.requestId = requestId,
.toolCallId = "tool-" + requestId,
.title = "Edit main.cpp",
.toolKind = toolKind,
.options = editPermissionOptions()};
}
Session::PermissionBlock permissionRowAt(const Session::Session &session, int index)
{
return Session::decodePermissionBlock(session.rows().at(index).content)
.value_or(Session::PermissionBlock{});
}
Session::ToolCallUpdated llmToolStarted(
const QString &turnId,
const QString &toolId,
const QString &name,
const QJsonObject &arguments = {},
bool dropPrecedingText = false)
{
return Session::ToolCallUpdated{
.turnId = turnId,
.toolId = toolId,
.name = name,
.status = "in_progress",
.arguments = arguments,
.dropPrecedingText = dropPrecedingText};
}
Session::ToolCallUpdated llmToolCompleted(
const QString &turnId, const QString &toolId, const QString &name, const QString &result)
{
return Session::ToolCallUpdated{
.turnId = turnId,
.toolId = toolId,
.name = name,
.status = "completed",
.result = result};
}
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"},
llmToolStarted(turnId, "t1", "read_file", QJsonObject{{"path", "main.cpp"}}),
llmToolCompleted(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:
explicit FakeProjectContext(Session::ProjectInfo info)
: m_info(std::move(info))
{}
Session::ProjectInfo projectInfo() const override { return m_info; }
private:
Session::ProjectInfo m_info;
};
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;
};
void attachModelToSession(Session::Session &session, Chat::ChatModel &model)
{
QObject::connect(
&session, &Session::Session::rowsReset, &model, &Chat::ChatModel::resetMessages);
QObject::connect(
&session, &Session::Session::rowsAppended, &model, &Chat::ChatModel::appendMessages);
QObject::connect(
&session, &Session::Session::rowUpdated, &model, &Chat::ChatModel::updateMessage);
QObject::connect(
&session, &Session::Session::rowsRemoved, &model, &Chat::ChatModel::removeMessages);
model.resetMessages(session.rows());
}
Session::ProjectInfo sampleProject()
{
Session::ProjectInfo info;
info.available = true;
info.displayName = "QodeAssist";
info.sourceRoot = "/src/QodeAssist";
info.buildDirectory = "/src/QodeAssist/build";
return info;
}
Session::ConversationHistory historyWithEveryRowKind()
{
Session::Message system;
system.role = Session::MessageRole::System;
system.id = "s1";
system.blocks = {Session::TextBlock{"system"}};
Session::Message user;
user.role = Session::MessageRole::User;
user.id = "u1";
user.blocks = {Session::TextBlock{"question"}};
Session::Message assistant;
assistant.role = Session::MessageRole::Assistant;
assistant.id = "a1";
assistant.blocks
= {Session::TextBlock{"answer"},
Session::ThinkingBlock{"thinking", {}, false},
Session::ToolCallBlock{"call-1", "read_file", {}, "int main() {}", {}, {}, {}, false},
Session::ToolCallBlock{
"call-2", "Edit main.cpp", {}, "done", "edit", "completed", {}, true},
Session::FileEditBlock{"edit-1", "payload"},
Session::PermissionBlock{"p1", "tool-p1", "Edit main.cpp", "edit", {}},
Session::PlanBlock{{Session::PlanEntry{"Step", "high", "pending"}}}};
Session::ConversationHistory history;
history.append(system);
history.append(user);
history.append(assistant);
return history;
}
QList<Session::RowKind> projectedKinds()
{
QList<Session::RowKind> kinds;
for (const Session::MessageRow &row : Session::projectToRows(historyWithEveryRowKind()))
kinds.append(row.kind);
return kinds;
}
QList<Session::RowKind> survivingKinds(Session::RowAudience audience)
{
QList<Session::RowKind> kept;
for (const Session::MessageRow &row : Session::projectToRows(historyWithEveryRowKind())) {
if (Session::rowTreatmentFor(audience, row.kind) != Session::RowTreatment::Omit)
kept.append(row.kind);
}
return kept;
}
} // 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);
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);
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;
attachModelToSession(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);
QCOMPARE(backend.turnCount(), 1);
QCOMPARE(backend.historySizeAtSend(), qsizetype(1));
QCOMPARE(backend.lastUserBlocks().size(), 2);
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() {}",
{},
"completed"}}));
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::testSwitchingBackendsCancelsTheActiveTurn()
{
FakeChatBackend first;
FakeChatBackend second;
Session::Session session;
session.setBackend(&first);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt);
first.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}});
const int cancelsBeforeSwap = first.cancelCount();
session.setBackend(&second);
QCOMPARE(first.cancelCount(), cancelsBeforeSwap + 1);
const qsizetype rowsAfterSwap = session.rows().size();
first.script({Session::TextDelta{"r1", " stale"}});
QCOMPARE(session.rows().size(), rowsAfterSwap);
QCOMPARE(session.rows().at(1).content, QString("partial"));
}
void QodeAssistTest::testSessionForwardsTitleFromTheEventStream()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
QStringList titles;
connect(
&session,
&Session::Session::sessionInfoReceived,
this,
[&titles](const QString &title) { titles.append(title); });
session.sendTurn({Session::TextBlock{"hi"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
Session::TextDelta{"r1", "hello"},
Session::SessionInfo{.title = "Working title"},
Session::TurnCompleted{"r1"},
Session::SessionInfo{.title = "Chat about parsers"}});
QCOMPARE(titles, (QStringList{"Working title", "Chat about parsers"}));
backend.script({Session::SessionInfo{.title = QString()}});
QCOMPARE(titles.size(), 2);
}
void QodeAssistTest::testSessionProjectionMatchesHistory()
{
Chat::ChatModel model;
FakeChatBackend backend;
Session::Session session;
attachModelToSession(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},
llmToolStarted("r1", "t1", "read_file"),
llmToolCompleted("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"},
llmToolStarted("r1", "t1", "read_file", {}, true),
llmToolCompleted("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", {}, "completed"}}));
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"},
llmToolStarted("r1", "t1", "edit_file"),
llmToolCompleted("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::testSessionRepeatedToolResultKeepsOneFileEdit()
{
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"},
llmToolStarted("r1", "t1", "edit_file"),
llmToolCompleted("r1", "t1", "edit_file", result),
llmToolCompleted("r1", "t1", "edit_file", result)});
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.blocks.size(), 2);
qsizetype editBlocks = 0;
for (const Session::ContentBlock &block : assistant.blocks) {
if (std::holds_alternative<Session::FileEditBlock>(block))
++editBlocks;
}
QCOMPARE(editBlocks, qsizetype(1));
QCOMPARE(session.rows(), Session::projectToRows(session.history()));
}
void QodeAssistTest::testRunningToolStatusInterruptsOnTurnEnd()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, llmToolStarted("r1", "t1", "read_file")});
QCOMPARE(session.rows().at(1).toolStatus, QString("in_progress"));
session.cancel();
QCOMPARE(session.rows().at(1).toolStatus, QString("interrupted"));
const auto *tool
= std::get_if<Session::ToolCallBlock>(&session.history().at(1).blocks.at(0));
QVERIFY(tool);
QCOMPARE(tool->status, QString("interrupted"));
}
void QodeAssistTest::testRunningToolStatusInterruptsOnLoad()
{
Session::Message assistant;
assistant.role = Session::MessageRole::Assistant;
assistant.id = "r1";
assistant.blocks
= {Session::ToolCallBlock{
"call-1", "Edit main.cpp", QJsonObject{}, {}, "edit", "in_progress", {}, true}};
Session::ConversationHistory history;
history.append(assistant);
const auto reloaded
= Session::HistorySerializer::fromJson(Session::HistorySerializer::toJson(history));
QVERIFY(reloaded.has_value());
const auto *tool = std::get_if<Session::ToolCallBlock>(&reloaded->at(0).blocks.at(0));
QVERIFY(tool);
QCOMPARE(tool->status, QString("interrupted"));
const auto rebuilt = Session::buildFromRows(Session::projectToRows(history));
const auto *rowTool = std::get_if<Session::ToolCallBlock>(&rebuilt.at(0).blocks.at(0));
QVERIFY(rowTool);
QCOMPARE(rowTool->status, QString("interrupted"));
QCOMPARE(Session::restoredToolStatus("completed"), QString("completed"));
QCOMPARE(Session::restoredToolStatus("failed"), QString("failed"));
QCOMPARE(Session::restoredToolStatus(QString()), QString());
}
void QodeAssistTest::testAgentDiffBecomesAppliedFileEdit()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
struct Recorded
{
QString turnId;
QString editId;
QString filePath;
QString oldContent;
QString newContent;
};
QList<Recorded> recorded;
connect(
&session,
&Session::Session::agentFileEditRecorded,
this,
[&recorded](
const QString &turnId,
const QString &editId,
const QString &filePath,
const QString &oldContent,
const QString &newContent) {
recorded.append({turnId, editId, filePath, oldContent, newContent});
});
const QJsonObject details{
{"diffs",
QJsonArray{QJsonObject{
{"path", "/proj/main.cpp"}, {"oldText", "int a;"}, {"newText", "int b;"}}}}};
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Edit main.cpp",
.kind = "edit",
.status = "in_progress",
.fromAgent = true},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Edit main.cpp",
.kind = "edit",
.status = "completed",
.details = details,
.fromAgent = true},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Edit main.cpp",
.kind = "edit",
.status = "completed",
.details = details,
.fromAgent = true}});
QCOMPARE(recorded.size(), 1);
QCOMPARE(recorded.at(0).turnId, QString("r1"));
QCOMPARE(recorded.at(0).editId, QString("agent-call-1-0"));
QCOMPARE(recorded.at(0).filePath, QString("/proj/main.cpp"));
QCOMPARE(recorded.at(0).oldContent, QString("int a;"));
QCOMPARE(recorded.at(0).newContent, QString("int b;"));
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.blocks.size(), 2);
const auto *edit = std::get_if<Session::FileEditBlock>(&assistant.blocks.at(1));
QVERIFY(edit);
QCOMPARE(edit->id, QString("agent-call-1-0"));
const auto payload = Session::parseFileEditPayload(edit->payload);
QVERIFY(payload.has_value());
QCOMPARE(payload->value("status").toString(), QString("applied"));
QCOMPARE(payload->value("file").toString(), QString("/proj/main.cpp"));
QCOMPARE(payload->value("old_content").toString(), QString("int a;"));
QCOMPARE(payload->value("new_content").toString(), QString("int b;"));
const Session::MessageRow &editRow = session.rows().last();
QCOMPARE(editRow.kind, Session::RowKind::FileEdit);
QVERIFY(editRow.content.contains("\"status\":\"applied\""));
QCOMPARE(session.rows(), Session::projectToRows(session.history()));
}
void QodeAssistTest::testAgentDiffSkipsUnrevertableRegistrations()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
int recorded = 0;
connect(
&session,
&Session::Session::agentFileEditRecorded,
this,
[&recorded](
const QString &, const QString &, const QString &, const QString &, const QString &) {
++recorded;
});
const QJsonObject details{
{"diffs",
QJsonArray{
QJsonObject{{"path", "/proj/deleted.cpp"}, {"oldText", "gone"}, {"newText", ""}},
QJsonObject{
{"path", "/proj/big.cpp"},
{"oldText", "int a;"},
{"newText", "int b;"},
{"truncated", true}}}}};
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Edit files",
.kind = "edit",
.status = "completed",
.details = details,
.fromAgent = true}});
QCOMPARE(recorded, 0);
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.blocks.size(), 3);
QVERIFY(std::get_if<Session::FileEditBlock>(&assistant.blocks.at(1)));
QVERIFY(std::get_if<Session::FileEditBlock>(&assistant.blocks.at(2)));
QCOMPARE(session.rows(), Session::projectToRows(session.history()));
}
void QodeAssistTest::testAgentFileEditRevertRoundTrip()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
const QString filePath = dir.filePath("edited.cpp");
const QString oldContent = "int a;\n";
const QString newContent = "int b;\n";
{
QFile file(filePath);
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
file.write(newContent.toUtf8());
}
const QString editId
= "agent-test-" + QUuid::createUuid().toString(QUuid::WithoutBraces);
const QString requestId
= "req-" + QUuid::createUuid().toString(QUuid::WithoutBraces);
auto &changes = Context::FileEditManager::instance();
changes.registerAppliedFileEdit(editId, filePath, oldContent, newContent, requestId);
const auto registered = changes.getFileEdit(editId);
QCOMPARE(registered.status, Context::FileEditManager::Applied);
QCOMPARE(registered.filePath, filePath);
QVERIFY(changes.applyPendingEditsForRequest(requestId));
QSignalSpy undoneSpy(&changes, &Context::FileEditManager::fileEditUndone);
QVERIFY(changes.undoFileEdit(editId));
QCOMPARE(undoneSpy.count(), 1);
QCOMPARE(undoneSpy.at(0).at(0).toString(), editId);
QFile file(filePath);
QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text));
QCOMPARE(QString::fromUtf8(file.readAll()), oldContent);
QCOMPARE(changes.getFileEdit(editId).status, Context::FileEditManager::Rejected);
}
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"},
llmToolStarted("r1", "t1", "read_original_history"),
llmToolCompleted("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"},
llmToolStarted("other", "t9", "read_file")});
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;
attachModelToSession(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::testSessionUpsertsAgentToolCalls()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Edit main.cpp",
.kind = "edit",
.status = "pending",
.fromAgent = true},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Edit main.cpp",
.kind = "edit",
.status = "completed",
.result = "done",
.details = QJsonObject{{"locations", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}},
.fromAgent = true}});
QCOMPARE(session.history().size(), 2);
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.blocks.size(), 1);
const auto *tool = std::get_if<Session::ToolCallBlock>(&assistant.blocks.at(0));
QVERIFY(tool);
QCOMPARE(tool->id, QString("call-1"));
QCOMPARE(tool->name, QString("Edit main.cpp"));
QCOMPARE(tool->kind, QString("edit"));
QCOMPARE(tool->status, QString("completed"));
QCOMPARE(tool->result, QString("done"));
QVERIFY(tool->details.contains("locations"));
QCOMPARE(session.rows().size(), 2);
QCOMPARE(session.rows().at(1).kind, Session::RowKind::AgentTool);
QCOMPARE(session.rows().at(1).toolStatus, QString("completed"));
QCOMPARE(session.rows().at(1).toolKind, QString("edit"));
QCOMPARE(session.rows().at(1).toolDetails, tool->details);
QVERIFY(tool->fromAgent);
}
void QodeAssistTest::testLlmToolLifecycleFlowsThroughOneEvent()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
llmToolStarted("r1", "t1", "read_file", QJsonObject{{"path", "main.cpp"}})});
QCOMPARE(session.rows().size(), 2);
QCOMPARE(session.rows().at(1).kind, Session::RowKind::Tool);
QCOMPARE(session.rows().at(1).toolStatus, QString("in_progress"));
backend.script(
{Session::TextDelta{"r1", "working"},
llmToolCompleted("r1", "t1", "read_file", "int main() {}"),
Session::TextDelta{"r1", " still"}});
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.blocks.size(), 2);
const auto *tool = std::get_if<Session::ToolCallBlock>(&assistant.blocks.at(0));
QVERIFY(tool);
QCOMPARE(tool->status, QString("completed"));
QCOMPARE(tool->result, QString("int main() {}"));
QCOMPARE(tool->arguments, (QJsonObject{{"path", "main.cpp"}}));
QVERIFY(!tool->fromAgent);
QCOMPARE(
assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"working still"}}));
QCOMPARE(session.rows().at(1).toolStatus, QString("completed"));
QCOMPARE(session.rows(), Session::projectToRows(session.history()));
}
void QodeAssistTest::testSessionKeepsOnePlanPerTurn()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"fix it"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
Session::PlanUpdated{
.turnId = "r1",
.entries = {Session::PlanEntry{"Read", "high", "in_progress"},
Session::PlanEntry{"Fix", "medium", "pending"}}},
Session::TextDelta{"r1", "working"},
Session::PlanUpdated{
.turnId = "r1",
.entries = {Session::PlanEntry{"Read", "high", "completed"},
Session::PlanEntry{"Fix", "medium", "in_progress"}}}});
const Session::Message &assistant = session.history().at(1);
qsizetype planBlocks = 0;
const Session::PlanBlock *plan = nullptr;
for (const Session::ContentBlock &block : assistant.blocks) {
if (const auto *candidate = std::get_if<Session::PlanBlock>(&block)) {
++planBlocks;
plan = candidate;
}
}
QCOMPARE(planBlocks, qsizetype(1));
QVERIFY(plan);
QCOMPARE(plan->entries.size(), 2);
QCOMPARE(plan->entries.at(0).status, QString("completed"));
QCOMPARE(plan->entries.at(1).status, QString("in_progress"));
qsizetype planRows = 0;
for (const Session::MessageRow &row : session.rows()) {
if (row.kind == Session::RowKind::Plan)
++planRows;
}
QCOMPARE(planRows, qsizetype(1));
}
void QodeAssistTest::testAgentActivityKeepsStreamedTextIntact()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Read main.cpp",
.status = "pending",
.fromAgent = true},
Session::TextDelta{"r1", "Reading the file"},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Read main.cpp",
.status = "completed",
.fromAgent = true},
Session::TextDelta{"r1", " and it is done."}});
const Session::Message &assistant = session.history().at(1);
QStringList streamed;
for (const Session::ContentBlock &block : assistant.blocks) {
if (const auto *text = std::get_if<Session::TextBlock>(&block))
streamed.append(text->text);
}
QCOMPARE(streamed, QStringList{"Reading the file and it is done."});
}
void QodeAssistTest::testAgentToolUpdateTouchesOnlyItsOwnRow()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Read a",
.status = "pending",
.fromAgent = true},
Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-2",
.name = "Read b",
.status = "pending",
.fromAgent = true}});
QList<int> updatedRows;
QObject::connect(
&session,
&Session::Session::rowUpdated,
&session,
[&updatedRows](int index, const Session::MessageRow &) { updatedRows.append(index); });
backend.script(
{Session::ToolCallUpdated{
.turnId = "r1",
.toolId = "call-1",
.name = "Read a",
.status = "completed",
.fromAgent = true}});
QCOMPARE(updatedRows, QList<int>{1});
QCOMPARE(session.rows().at(1).toolStatus, QString("completed"));
QCOMPARE(session.rows().at(2).toolStatus, QString("pending"));
}
void QodeAssistTest::testUsageSkipsRowsThatCannotShowIt()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
Session::PlanUpdated{
.turnId = "r1", .entries = {Session::PlanEntry{"Read", "high", "in_progress"}}},
Session::TextDelta{"r1", "here is the answer"},
Session::UsageReported{"r1", Session::Usage{120, 40, 80, 12}}});
const Session::MessageRow &planRow = session.rows().at(1);
const Session::MessageRow &textRow = session.rows().at(2);
QCOMPARE(planRow.kind, Session::RowKind::Plan);
QVERIFY(planRow.usage.isEmpty());
QCOMPARE(textRow.kind, Session::RowKind::Assistant);
QCOMPARE(textRow.usage, (Session::Usage{120, 40, 80, 12}));
}
void QodeAssistTest::testAgentActivityBlocksSurviveReload()
{
Session::Message assistant;
assistant.role = Session::MessageRole::Assistant;
assistant.id = "r1";
assistant.blocks
= {Session::PlanBlock{
{Session::PlanEntry{"Read", "high", "completed"},
Session::PlanEntry{"Fix", "medium", "in_progress"}}},
Session::ToolCallBlock{
"call-1",
"Edit main.cpp",
QJsonObject{},
"done",
"edit",
"completed",
QJsonObject{{"diffs", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}},
true}};
Session::ConversationHistory history;
history.append(assistant);
const auto reloaded = Session::HistorySerializer::fromJson(
Session::HistorySerializer::toJson(history));
QVERIFY(reloaded.has_value());
QCOMPARE(*reloaded, history);
const QList<Session::MessageRow> rows = Session::projectToRows(history);
QCOMPARE(rows.size(), 2);
QCOMPARE(rows.at(0).kind, Session::RowKind::Plan);
QCOMPARE(rows.at(1).kind, Session::RowKind::AgentTool);
QVERIFY(Session::isTranscriptOnlyRow(rows.at(0).kind));
QVERIFY(Session::isTranscriptOnlyRow(rows.at(1).kind));
QCOMPARE(Session::buildFromRows(rows), history);
}
void QodeAssistTest::testEveryRowKindProjectsInOrder()
{
QCOMPARE(
projectedKinds(),
(QList<Session::RowKind>{
Session::RowKind::System,
Session::RowKind::User,
Session::RowKind::Assistant,
Session::RowKind::Thinking,
Session::RowKind::Tool,
Session::RowKind::AgentTool,
Session::RowKind::FileEdit,
Session::RowKind::Permission,
Session::RowKind::Plan}));
}
void QodeAssistTest::testPromptAudienceKeepsToolsAndThinking()
{
QCOMPARE(
survivingKinds(Session::RowAudience::Prompt),
(QList<Session::RowKind>{
Session::RowKind::System,
Session::RowKind::User,
Session::RowKind::Assistant,
Session::RowKind::Thinking,
Session::RowKind::Tool}));
QCOMPARE(
Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::User),
Session::RowTreatment::UserText);
QCOMPARE(
Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::System),
Session::RowTreatment::AssistantText);
QCOMPARE(
Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::Thinking),
Session::RowTreatment::AssistantThinking);
QCOMPARE(
Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::Tool),
Session::RowTreatment::ToolExchange);
}
void QodeAssistTest::testCompressionAudienceDropsToolsAndThinking()
{
QCOMPARE(
survivingKinds(Session::RowAudience::Compression),
(QList<Session::RowKind>{
Session::RowKind::System, Session::RowKind::User, Session::RowKind::Assistant}));
}
void QodeAssistTest::testTokenCountAudienceCountsToolsAndThinking()
{
QCOMPARE(
survivingKinds(Session::RowAudience::TokenCount),
(QList<Session::RowKind>{
Session::RowKind::System,
Session::RowKind::User,
Session::RowKind::Assistant,
Session::RowKind::Thinking,
Session::RowKind::Tool}));
}
void QodeAssistTest::testSessionRendersPermissionRequestWithItsOptions()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")});
QCOMPARE(session.rows().size(), 2);
QCOMPARE(session.rows().at(1).kind, Session::RowKind::Permission);
QCOMPARE(session.rows().at(1).id, QString("p1"));
const Session::PermissionBlock block = permissionRowAt(session, 1);
QCOMPARE(block.requestId, QString("p1"));
QCOMPARE(block.toolCallId, QString("tool-p1"));
QCOMPARE(block.title, QString("Edit main.cpp"));
QCOMPARE(block.toolKind, QString("edit"));
QCOMPARE(block.options, editPermissionOptions());
QCOMPARE(block.status, Session::PermissionStatus::Pending);
QVERIFY(!block.automatic);
QVERIFY(session.isPermissionPending("p1"));
QVERIFY(backend.permissionAnswers().isEmpty());
}
void QodeAssistTest::testSessionForwardsPermissionAnswerToTheBackend()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")});
session.respondPermission("p1", "opt-no");
QCOMPARE(backend.permissionAnswers().size(), 1);
QCOMPARE(backend.permissionAnswers().at(0).first, QString("p1"));
QCOMPARE(backend.permissionAnswers().at(0).second, QString("opt-no"));
const Session::PermissionBlock block = permissionRowAt(session, 1);
QCOMPARE(block.status, Session::PermissionStatus::Answered);
QCOMPARE(block.selectedOptionId, QString("opt-no"));
QVERIFY(!block.automatic);
QVERIFY(!session.isPermissionPending("p1"));
}
void QodeAssistTest::testSessionAllowAlwaysSuppressesRepeatPromptsOfTheSameKind()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"edit them"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")});
session.respondPermission("p1", "opt-always");
backend.script({editPermissionRequest("r1", "p2")});
QCOMPARE(backend.permissionAnswers().size(), 2);
QCOMPARE(backend.permissionAnswers().at(1).first, QString("p2"));
QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-once"));
const Session::PermissionBlock block = permissionRowAt(session, 2);
QCOMPARE(block.requestId, QString("p2"));
QCOMPARE(block.status, Session::PermissionStatus::Answered);
QCOMPARE(block.selectedOptionId, QString("opt-once"));
QVERIFY(block.automatic);
QVERIFY(!session.isPermissionPending("p2"));
}
void QodeAssistTest::testSessionAllowAlwaysDoesNotCoverOtherToolKinds()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"work"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", "edit")});
session.respondPermission("p1", "opt-always");
backend.script({editPermissionRequest("r1", "p2", "execute")});
QCOMPARE(backend.permissionAnswers().size(), 1);
QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending);
QVERIFY(session.isPermissionPending("p2"));
}
void QodeAssistTest::testSessionNeverAutoAnswersUnclassifiedTools()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"work"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", QString())});
session.respondPermission("p1", "opt-always");
backend.script({editPermissionRequest("r1", "p2", QString())});
QCOMPARE(backend.permissionAnswers().size(), 1);
QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending);
}
void QodeAssistTest::testSessionIgnoresAnswersToUnknownPermissionRequests()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")});
session.respondPermission("nonexistent", "opt-once");
session.respondPermission("p1", "no-such-option");
session.respondPermission("p1", "opt-once");
session.respondPermission("p1", "opt-no");
QCOMPARE(backend.permissionAnswers().size(), 1);
QCOMPARE(backend.permissionAnswers().at(0).second, QString("opt-once"));
QCOMPARE(permissionRowAt(session, 1).selectedOptionId, QString("opt-once"));
}
void QodeAssistTest::testSessionMarksUnansweredPermissionsCancelled()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")});
backend.script(
{Session::PermissionResolved{.turnId = "r1", .requestId = "p1", .cancelled = true},
Session::TurnFailed{"r1", "the agent went away"}});
const Session::PermissionBlock block = permissionRowAt(session, 1);
QCOMPARE(block.status, Session::PermissionStatus::Cancelled);
QVERIFY(block.selectedOptionId.isEmpty());
QVERIFY(!session.isPermissionPending("p1"));
session.respondPermission("p1", "opt-once");
QVERIFY(backend.permissionAnswers().isEmpty());
}
void QodeAssistTest::testSessionRejectAlwaysSuppressesRepeatPromptsOfTheSameKind()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
Session::PermissionRequested first = editPermissionRequest("r1", "p1");
first.options.append(
Session::PermissionOption{"opt-never", "No, and don't ask again", "reject_always"});
session.sendTurn({Session::TextBlock{"work"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, first});
session.respondPermission("p1", "opt-never");
Session::PermissionRequested second = editPermissionRequest("r1", "p2");
second.options.append(
Session::PermissionOption{"opt-never", "No, and don't ask again", "reject_always"});
backend.script({second});
QCOMPARE(backend.permissionAnswers().size(), 2);
QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-no"));
const Session::PermissionBlock block = permissionRowAt(session, 2);
QCOMPARE(block.status, Session::PermissionStatus::Answered);
QCOMPARE(block.selectedOptionId, QString("opt-no"));
QVERIFY(block.automatic);
}
void QodeAssistTest::testSessionAutoAnswerFallsBackToTheAlwaysOption()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"work"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")});
session.respondPermission("p1", "opt-always");
Session::PermissionRequested withoutOnce = editPermissionRequest("r1", "p2");
withoutOnce.options = {
Session::PermissionOption{"opt-always", "Yes, always", "allow_always"},
Session::PermissionOption{"opt-no", "No", "reject_once"}};
backend.script({withoutOnce});
QCOMPARE(backend.permissionAnswers().size(), 2);
QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-always"));
}
void QodeAssistTest::testSessionDoesNotAutoAnswerWithoutAMatchingOption()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"work"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")});
session.respondPermission("p1", "opt-always");
Session::PermissionRequested rejectOnly = editPermissionRequest("r1", "p2");
rejectOnly.options = {Session::PermissionOption{"opt-no", "No", "reject_once"}};
backend.script({rejectOnly});
QCOMPARE(backend.permissionAnswers().size(), 1);
QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending);
QVERIFY(session.isPermissionPending("p2"));
}
void QodeAssistTest::testSessionAllowAlwaysDoesNotSurviveAnotherConversation()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"work"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")});
session.respondPermission("p1", "opt-always");
QCOMPARE(backend.permissionAnswers().size(), 1);
session.setHistory(Session::ConversationHistory{});
session.sendTurn({Session::TextBlock{"work again"}}, std::nullopt);
backend.script({Session::TurnStarted{"r2"}, editPermissionRequest("r2", "p2")});
QCOMPARE(backend.permissionAnswers().size(), 1);
QCOMPARE(permissionRowAt(session, 1).status, Session::PermissionStatus::Pending);
}
void QodeAssistTest::testSessionAnswersTheLiveRequestWhenAnOldIdIsReused()
{
Session::Message assistant;
assistant.role = Session::MessageRole::Assistant;
assistant.id = "old";
assistant.blocks
= {Session::PermissionBlock{
.requestId = "p1",
.toolCallId = "tool-old",
.title = "Run make",
.toolKind = "execute",
.options = editPermissionOptions(),
.status = Session::PermissionStatus::Cancelled}};
Session::ConversationHistory history;
history.append(assistant);
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.setHistory(history);
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", "edit")});
session.respondPermission("p1", "opt-always");
QCOMPARE(backend.permissionAnswers().size(), 1);
const Session::PermissionBlock stale = permissionRowAt(session, 0);
QCOMPARE(stale.status, Session::PermissionStatus::Cancelled);
QVERIFY(stale.selectedOptionId.isEmpty());
const Session::PermissionBlock live = permissionRowAt(session, 2);
QCOMPARE(live.status, Session::PermissionStatus::Answered);
QCOMPARE(live.selectedOptionId, QString("opt-always"));
backend.script({editPermissionRequest("r1", "p3", "edit")});
QCOMPARE(backend.permissionAnswers().size(), 2);
backend.script({editPermissionRequest("r1", "p4", "execute")});
QCOMPARE(backend.permissionAnswers().size(), 2);
}
void QodeAssistTest::testSessionCancelsPermissionsTheBackendNeverResolved()
{
FakeChatBackend backend;
backend.setAutoResolvePermissions(false);
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script(
{Session::TurnStarted{"r1"},
editPermissionRequest("r1", "p1"),
Session::TurnCompleted{"r1"}});
QCOMPARE(permissionRowAt(session, 1).status, Session::PermissionStatus::Cancelled);
QVERIFY(!session.isPermissionPending("p1"));
}
void QodeAssistTest::testSessionCancelsPermissionsTheBackendRefused()
{
FakeChatBackend backend;
backend.setAcceptPermissionAnswers(false);
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt);
backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")});
session.respondPermission("p1", "opt-always");
const Session::PermissionBlock block = permissionRowAt(session, 1);
QCOMPARE(block.status, Session::PermissionStatus::Cancelled);
QVERIFY(block.selectedOptionId.isEmpty());
QVERIFY(!session.isPermissionPending("p1"));
backend.script({editPermissionRequest("r1", "p2")});
QCOMPARE(backend.permissionAnswers().size(), 1);
QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending);
}
void QodeAssistTest::testPermissionBlockReopensAsUnanswerable()
{
Session::Message assistant;
assistant.role = Session::MessageRole::Assistant;
assistant.id = "r1";
assistant.blocks
= {Session::PermissionBlock{
.requestId = "p1",
.toolCallId = "tool-1",
.title = "Edit main.cpp",
.toolKind = "edit",
.options = editPermissionOptions(),
.status = Session::PermissionStatus::Answered,
.selectedOptionId = "opt-once",
.automatic = false},
Session::PermissionBlock{
.requestId = "p2",
.toolCallId = "tool-2",
.title = "Run make",
.toolKind = "execute",
.options = editPermissionOptions(),
.status = Session::PermissionStatus::Pending}};
Session::ConversationHistory history;
history.append(assistant);
const QJsonObject json = Session::HistorySerializer::toJson(history);
const auto reloaded = Session::HistorySerializer::fromJson(json);
QVERIFY(reloaded.has_value());
QCOMPARE(reloaded->size(), 1);
QCOMPARE(reloaded->at(0).blocks.size(), 2);
const auto *answered = std::get_if<Session::PermissionBlock>(&reloaded->at(0).blocks.at(0));
QVERIFY(answered);
QCOMPARE(*answered, std::get<Session::PermissionBlock>(assistant.blocks.at(0)));
const auto *stale = std::get_if<Session::PermissionBlock>(&reloaded->at(0).blocks.at(1));
QVERIFY(stale);
QCOMPARE(stale->status, Session::PermissionStatus::Cancelled);
QCOMPARE(stale->options, editPermissionOptions());
const QList<Session::MessageRow> rows = Session::projectToRows(*reloaded);
QCOMPARE(rows.size(), 2);
QCOMPARE(rows.at(0).kind, Session::RowKind::Permission);
const Session::ConversationHistory rebuilt = Session::buildFromRows(rows);
QCOMPARE(rebuilt.size(), 1);
QCOMPARE(rebuilt.at(0).blocks, reloaded->at(0).blocks);
}
void QodeAssistTest::testTurnContextCollectsPartsFromPorts()
{
FakeProjectContext projectPort(sampleProject());
FakeSkillsContext skillsPort(
"# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}});
Session::TurnContextRequest request;
request.message = "please /review this";
request.basePrompt = "You are a helpful assistant.";
const Session::TurnContext context
= Session::TurnContextBuilder(projectPort, &skillsPort).build(request);
QCOMPARE(context.basePrompt, QString("You are a helpful assistant."));
QVERIFY(context.project.available);
QCOMPARE(context.project.displayName, QString("QodeAssist"));
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"));
}
void QodeAssistTest::testTurnContextSkipsWhatTheBackendDoesNotNeed()
{
FakeProjectContext projectPort(sampleProject());
FakeSkillsContext skillsPort(
"# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}});
Session::TurnContextRequest request;
request.message = "please /review this";
request.needs = Session::TurnContextNeeds{false};
const Session::TurnContext context
= Session::TurnContextBuilder(projectPort, &skillsPort).build(request);
QVERIFY(context.alwaysOnSkills.isEmpty());
QVERIFY(context.skillsCatalog.isEmpty());
QVERIFY(context.invokedSkills.isEmpty());
}
void QodeAssistTest::testFencedFileBlockOutgrowsBackticksInContent()
{
const QString plain = Session::fencedFileBlock("notes.txt", "hello");
QCOMPARE(plain, QString("File: notes.txt\n```\nhello\n```"));
const QString fenced = Session::fencedFileBlock("readme.md", "text\n```\ncode\n```\nmore");
QVERIFY(fenced.startsWith("File: readme.md\n````\n"));
QVERIFY(fenced.endsWith("\n````"));
QVERIFY(fenced.contains("```\ncode\n```"));
const QString nested = Session::fencedFileBlock("deep.md", "`````");
QVERIFY(nested.contains("``````"));
}
void QodeAssistTest::testTurnContextSkillCommandScanning()
{
FakeProjectContext projectPort({});
FakeSkillsContext skillsPort(
QString(),
QString(),
{Session::InvokedSkill{"review", "Review body"},
Session::InvokedSkill{"docs", "Docs body"},
Session::InvokedSkill{"blank", QString()}});
Session::TurnContextRequest request;
request.message = "/review then /docs then /review again /blank /unknown and mid/word";
const Session::TurnContext context
= Session::TurnContextBuilder(projectPort, &skillsPort).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());
Session::TurnContextRequest request;
request.message = "/review this";
request.basePrompt = "base";
const Session::TurnContext context
= Session::TurnContextBuilder(projectPort, nullptr).build(request);
QVERIFY(context.alwaysOnSkills.isEmpty());
QVERIFY(context.skillsCatalog.isEmpty());
QVERIFY(context.invokedSkills.isEmpty());
}
void QodeAssistTest::testSystemPromptRenderingWithProject()
{
Session::TurnContext context;
context.basePrompt = "You are helpful.";
context.project = sampleProject();
context.alwaysOnSkills = "# Always on";
context.skillsCatalog = "# Catalog";
context.invokedSkills = {Session::InvokedSkill{"review", "Review body"}};
const QString expected
= "You are helpful."
"\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\n# Always on"
"\n\n# Catalog"
"\n\n# Invoked Skill: review\n\nReview body";
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"));
}
void QodeAssistTest::testAgentRegistryParsesEveryDistributionKind()
{
const QByteArray payload = R"({"version":"1.0.0","agents":[
{"id":"npx-agent","name":"Npx Agent","version":"1.2.3","description":"An agent",
"authors":["Ada","Grace"],"license":"MIT","repository":"https://example.com/repo",
"website":"https://example.com","icon":"https://example.com/icon.svg",
"distribution":{"npx":{"package":"pkg@1.2.3","args":["--acp"],"env":{"KEY":"VALUE"}}}},
{"id":"uvx-agent","name":"Uvx Agent",
"distribution":{"uvx":{"package":"pypkg==1.0","args":["-x"]}}},
{"id":"binary-agent","name":"Binary Agent","distribution":{"binary":{
"darwin-aarch64":{"archive":"https://example.com/a.tar.gz","sha256":"abc",
"cmd":"./dist/agent","args":["acp"]}}}},
{"id":"command-agent","name":"Command Agent",
"distribution":{"command":{"cmd":"/usr/local/bin/agent","args":["acp"]}}}
]})";
const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse(
payload, Acp::AgentSource::LiveRegistry, "registry");
QVERIFY(result.warnings.isEmpty());
QCOMPARE(result.agents.size(), 4);
const Acp::AgentDefinition &npx = result.agents.at(0);
QCOMPARE(npx.id, QString("npx-agent"));
QCOMPARE(npx.name, QString("Npx Agent"));
QCOMPARE(npx.version, QString("1.2.3"));
QCOMPARE(npx.description, QString("An agent"));
QCOMPARE(npx.authors, (QStringList{"Ada", "Grace"}));
QCOMPARE(npx.license, QString("MIT"));
QCOMPARE(npx.repository, QString("https://example.com/repo"));
QCOMPARE(npx.source, Acp::AgentSource::LiveRegistry);
QCOMPARE(npx.origin, QString("registry"));
QCOMPARE(npx.distribution.kind, Acp::AgentDistributionKind::Npx);
QCOMPARE(npx.distribution.package, QString("pkg@1.2.3"));
QCOMPARE(npx.distribution.args, (QStringList{"--acp"}));
QCOMPARE(npx.distribution.env.size(), 1);
QCOMPARE(npx.distribution.env.at(0).name, QString("KEY"));
QCOMPARE(npx.distribution.env.at(0).value, QString("VALUE"));
QVERIFY(npx.isLaunchable());
const Acp::AgentDefinition &uvx = result.agents.at(1);
QCOMPARE(uvx.distribution.kind, Acp::AgentDistributionKind::Uvx);
QCOMPARE(uvx.distribution.package, QString("pypkg==1.0"));
QVERIFY(uvx.isLaunchable());
const Acp::AgentDefinition &binary = result.agents.at(2);
QCOMPARE(binary.distribution.kind, Acp::AgentDistributionKind::Binary);
QCOMPARE(binary.distribution.binaryTargets.size(), 1);
QCOMPARE(binary.distribution.binaryTargets.at(0).platform, QString("darwin-aarch64"));
QCOMPARE(binary.distribution.binaryTargets.at(0).cmd, QString("./dist/agent"));
QCOMPARE(binary.distribution.binaryTargets.at(0).sha256, QString("abc"));
QVERIFY(!binary.isLaunchable());
const Acp::AgentDefinition &command = result.agents.at(3);
QCOMPARE(command.distribution.kind, Acp::AgentDistributionKind::Command);
QCOMPARE(command.distribution.command, QString("/usr/local/bin/agent"));
QVERIFY(command.isLaunchable());
}
void QodeAssistTest::testAgentRegistryReportsUnusableEntries()
{
const QByteArray payload = R"({"agents":[
{"name":"No Id","distribution":{"npx":{"package":"p"}}},
{"id":"no-distribution","name":"No Distribution"},
{"id":"usable","name":"Usable","distribution":{"npx":{"package":"p"}}}
]})";
const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse(
payload, Acp::AgentSource::LiveRegistry, "registry");
QCOMPARE(result.agents.size(), 2);
QCOMPARE(result.warnings.size(), 2);
QVERIFY(result.warnings.at(0).contains("entry 0"));
QVERIFY(result.warnings.at(1).contains("no-distribution"));
QCOMPARE(result.agents.at(0).id, QString("no-distribution"));
QVERIFY(!result.agents.at(0).isLaunchable());
QVERIFY(result.agents.at(1).isLaunchable());
}
void QodeAssistTest::testAgentRegistryParsesSingleAgentUserFile()
{
const QByteArray payload
= R"({"id":"local","distribution":{"command":{"cmd":"/opt/local-agent"}}})";
const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse(
payload, Acp::AgentSource::UserFile, "local.json");
QCOMPARE(result.agents.size(), 1);
QCOMPARE(result.agents.at(0).id, QString("local"));
QCOMPARE(result.agents.at(0).name, QString("local"));
QCOMPARE(result.agents.at(0).source, Acp::AgentSource::UserFile);
QVERIFY(result.agents.at(0).isLaunchable());
}
void QodeAssistTest::testAgentCatalogAppliesMergePriority()
{
const QByteArray bundled = R"({"agents":[
{"id":"alpha","name":"Alpha Bundled","distribution":{"npx":{"package":"alpha@1"}}},
{"id":"beta","name":"Beta","distribution":{"npx":{"package":"beta@1"}}}
]})";
const QByteArray live = R"({"agents":[
{"id":"alpha","name":"Alpha Registry","distribution":{"npx":{"package":"alpha@2"}}},
{"id":"gamma","name":"Gamma","distribution":{"uvx":{"package":"gamma"}}}
]})";
const QByteArray user
= R"({"id":"alpha","name":"Alpha User","distribution":{"command":{"cmd":"/opt/alpha"}}})";
Acp::AgentCatalog catalog;
catalog.setLayer(
Acp::AgentSource::BundledSnapshot,
Acp::AgentRegistryParser::parse(bundled, Acp::AgentSource::BundledSnapshot, "bundled")
.agents);
catalog.setLayer(
Acp::AgentSource::LiveRegistry,
Acp::AgentRegistryParser::parse(live, Acp::AgentSource::LiveRegistry, "registry").agents);
catalog.setLayer(
Acp::AgentSource::UserFile,
Acp::AgentRegistryParser::parse(user, Acp::AgentSource::UserFile, "alpha.json").agents);
QCOMPARE(catalog.size(), 3);
QStringList names;
for (const Acp::AgentDefinition &agent : catalog.agents())
names.append(agent.name);
QCOMPARE(names, (QStringList{"Alpha User", "Beta", "Gamma"}));
auto alpha = catalog.agent("alpha");
QVERIFY(alpha.has_value());
QCOMPARE(alpha->source, Acp::AgentSource::UserFile);
QCOMPARE(alpha->distribution.kind, Acp::AgentDistributionKind::Command);
catalog.setLayer(Acp::AgentSource::UserFile, {});
alpha = catalog.agent("alpha");
QVERIFY(alpha.has_value());
QCOMPARE(alpha->name, QString("Alpha Registry"));
QCOMPARE(alpha->distribution.package, QString("alpha@2"));
catalog.setLayer(Acp::AgentSource::LiveRegistry, {});
alpha = catalog.agent("alpha");
QVERIFY(alpha.has_value());
QCOMPARE(alpha->name, QString("Alpha Bundled"));
}
void QodeAssistTest::testAgentCatalogUserFileMakesBinaryAgentLaunchable()
{
const QByteArray live = R"({"agents":[{"id":"cursor","name":"Cursor",
"distribution":{"binary":{"darwin-aarch64":{"archive":"https://example.com/a.tar.gz",
"cmd":"./cursor-agent","args":["acp"]}}}}]})";
const QByteArray user = R"({"id":"cursor","name":"Cursor",
"distribution":{"command":{"cmd":"/usr/local/bin/cursor-agent","args":["acp"]}}})";
Acp::AgentCatalog catalog;
catalog.setLayer(
Acp::AgentSource::LiveRegistry,
Acp::AgentRegistryParser::parse(live, Acp::AgentSource::LiveRegistry, "registry").agents);
QVERIFY(catalog.launchableAgents().isEmpty());
catalog.setLayer(
Acp::AgentSource::UserFile,
Acp::AgentRegistryParser::parse(user, Acp::AgentSource::UserFile, "cursor.json").agents);
QCOMPARE(catalog.size(), 1);
QCOMPARE(catalog.launchableAgents().size(), 1);
QCOMPARE(catalog.agent("cursor")->distribution.command, QString("/usr/local/bin/cursor-agent"));
}
void QodeAssistTest::testAgentLaunchConfigUsesRunnerConventions()
{
Acp::AgentDefinition npx;
npx.id = "npx-agent";
npx.distribution.kind = Acp::AgentDistributionKind::Npx;
npx.distribution.package = "pkg@1.0";
npx.distribution.args = QStringList{"--acp"};
npx.distribution.env = {{"KEY", "VALUE"}};
const auto npxConfig = Acp::agentLaunchConfig(npx, "/tmp/project");
QVERIFY(npxConfig.has_value());
QCOMPARE(npxConfig->cwd, QString("/tmp/project"));
QCOMPARE(npxConfig->command, QString("npx"));
QCOMPARE(npxConfig->args, (QStringList{"-y", "pkg@1.0", "--acp"}));
QCOMPARE(npxConfig->env.size(), 1);
QCOMPARE(npxConfig->env.at(0).name, QString("KEY"));
Acp::AgentDefinition uvx;
uvx.id = "uvx-agent";
uvx.distribution.kind = Acp::AgentDistributionKind::Uvx;
uvx.distribution.package = "pypkg==1.0";
uvx.distribution.args = QStringList{"-x"};
const auto uvxConfig = Acp::agentLaunchConfig(uvx, QString());
QVERIFY(uvxConfig.has_value());
QCOMPARE(uvxConfig->command, QString("uvx"));
QCOMPARE(uvxConfig->args, (QStringList{"pypkg==1.0", "-x"}));
Acp::AgentDefinition command;
command.id = "command-agent";
command.distribution.kind = Acp::AgentDistributionKind::Command;
command.distribution.command = "/opt/agent";
command.distribution.args = QStringList{"acp"};
const auto commandConfig = Acp::agentLaunchConfig(command, QString());
QVERIFY(commandConfig.has_value());
QCOMPARE(commandConfig->command, QString("/opt/agent"));
QCOMPARE(commandConfig->args, (QStringList{"acp"}));
Acp::AgentDefinition binary;
binary.id = "binary-agent";
binary.distribution.kind = Acp::AgentDistributionKind::Binary;
QVERIFY(!Acp::agentLaunchConfig(binary, QString()).has_value());
}
void QodeAssistTest::testAgentSearchPathsSplitting()
{
const QString separator(QDir::listSeparator());
QCOMPARE(Acp::splitSearchPaths(QString()), QStringList());
QCOMPARE(
Acp::splitSearchPaths(" /opt/homebrew/bin " + separator + separator + "/usr/local/bin"),
(QStringList{"/opt/homebrew/bin", "/usr/local/bin"}));
}
void QodeAssistTest::testAgentExtraSearchPathsReachTheChildEnvironment()
{
Acp::AgentDefinition agent;
agent.id = "npx-agent";
agent.distribution.kind = Acp::AgentDistributionKind::Npx;
agent.distribution.package = "pkg";
auto config = Acp::agentLaunchConfig(agent, QString());
QVERIFY(config.has_value());
Acp::applyExtraSearchPaths(*config, {});
QVERIFY(config->env.isEmpty());
QCOMPARE(config->command, QString("npx"));
Acp::applyExtraSearchPaths(*config, QStringList{"/no/such/qodeassist/dir"});
QCOMPARE(config->command, QString("npx"));
QCOMPARE(config->env.size(), 1);
QCOMPARE(config->env.at(0).name, QString("PATH"));
QVERIFY(config->env.at(0).value.startsWith("/no/such/qodeassist/dir"));
Acp::AgentDefinition withOwnPath;
withOwnPath.id = "own-path";
withOwnPath.distribution.kind = Acp::AgentDistributionKind::Npx;
withOwnPath.distribution.package = "pkg";
withOwnPath.distribution.env = {{"PATH", "/agent/own"}};
auto ownPathConfig = Acp::agentLaunchConfig(withOwnPath, QString());
QVERIFY(ownPathConfig.has_value());
Acp::applyExtraSearchPaths(*ownPathConfig, QStringList{"/extra"});
QCOMPARE(ownPathConfig->env.size(), 2);
QCOMPARE(ownPathConfig->env.at(0).name, QString("PATH"));
QCOMPARE(ownPathConfig->env.at(1).value, QString("/agent/own"));
}
void QodeAssistTest::testAgentForwardedVariablesReachTheChildEnvironment()
{
QCOMPARE(
Acp::splitVariableNames(" FOO, BAR\tBAZ "), (QStringList{"FOO", "BAR", "BAZ"}));
QCOMPARE(Acp::splitVariableNames(QString()), QStringList());
qputenv("QODEASSIST_TEST_TOKEN", "from-environment");
Acp::AgentDefinition agent;
agent.id = "npx-agent";
agent.distribution.kind = Acp::AgentDistributionKind::Npx;
agent.distribution.package = "pkg";
agent.distribution.env = {{"QODEASSIST_TEST_TOKEN", "from-definition"}};
auto config = Acp::agentLaunchConfig(agent, QString());
QVERIFY(config.has_value());
Acp::applyForwardedEnvironment(*config, {});
QCOMPARE(config->env.size(), 1);
Acp::applyForwardedEnvironment(*config, QStringList{"QODEASSIST_TEST_TOKEN"});
QCOMPARE(config->env.size(), 2);
QCOMPARE(config->env.at(0).name, QString("QODEASSIST_TEST_TOKEN"));
QCOMPARE(config->env.at(0).value, QString("from-environment"));
QCOMPARE(config->env.at(1).value, QString("from-definition"));
qunsetenv("QODEASSIST_TEST_TOKEN");
}
void QodeAssistTest::testBundledAgentSnapshotParses()
{
QFile file(Acp::AgentCatalogStore::bundledSnapshotPath());
QVERIFY2(file.open(QIODevice::ReadOnly), qPrintable(file.fileName()));
const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse(
file.readAll(), Acp::AgentSource::BundledSnapshot, "bundled snapshot");
QVERIFY(result.agents.size() > 10);
QVERIFY2(result.warnings.isEmpty(), qPrintable(result.warnings.join("; ")));
Acp::AgentCatalog catalog;
catalog.setLayer(Acp::AgentSource::BundledSnapshot, result.agents);
QVERIFY(!catalog.launchableAgents().isEmpty());
}
namespace {
Acp::AgentDefinition fakeAgentDefinition()
{
Acp::AgentDefinition agent;
agent.id = "fake-agent";
agent.name = "Fake Agent";
agent.distribution.kind = Acp::AgentDistributionKind::Command;
agent.distribution.command = "/bin/true";
return agent;
}
class AcpBackendFixture
{
public:
AcpBackendFixture()
{
backend.setClientFactory(
[this](const Acp::AgentDefinition &, const QString &cwd, QObject *parent) {
requestedCwd = cwd;
agent = new FakeAcpAgent(parent);
configure(agent);
return Acp::AgentProcess{
new LLMQore::Acp::AcpClient(
agent,
{QStringLiteral("QodeAssistTest"), QStringLiteral("1.0"), QString()},
parent),
QStringLiteral("fake")};
});
QObject::connect(
&backend,
&Session::ChatBackend::sessionEvent,
&backend,
[this](const Session::SessionEvent &event) { events.append(event); });
backend.setStoredContentLoader([this](const QString &, const QString &storedPath) {
return storage.value(storedPath);
});
backend.bindAgent(fakeAgentDefinition());
}
void send(const QString &text)
{
Session::TurnRequest request;
request.userBlocks = {Session::TextBlock{text}};
backend.sendTurn(request);
}
void sendBlocks(
const QList<Session::ContentBlock> &blocks,
const std::optional<Session::TurnContext> &context = std::nullopt)
{
Session::TurnRequest request;
request.userBlocks = blocks;
request.context = context;
backend.sendTurn(request);
}
QList<QJsonObject> promptBlocksOfType(const QString &type) const
{
QList<QJsonObject> matching;
if (!agent)
return matching;
const QList<QJsonArray> sent = agent->prompts();
if (sent.isEmpty())
return matching;
for (const QJsonValue &block : sent.constLast()) {
if (block.toObject().value("type").toString() == type)
matching.append(block.toObject());
}
return matching;
}
template<typename T>
QList<T> eventsOfType() const
{
QList<T> result;
for (const Session::SessionEvent &event : events) {
if (const auto *typed = std::get_if<T>(&event))
result.append(*typed);
}
return result;
}
std::function<void(FakeAcpAgent *)> configure = [](FakeAcpAgent *) {};
QHash<QString, QByteArray> storage;
FakeAcpAgent *agent = nullptr;
QString requestedCwd;
QList<Session::SessionEvent> events;
Acp::AcpChatBackend backend;
};
} // namespace
void QodeAssistTest::testAcpBackendStreamsTextAndThinking()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setThoughtChunks({"pondering"});
agent->setReplyChunks({"Hello", " world"});
};
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.eventsOfType<Session::TurnStarted>().size(), 1);
QCOMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 0);
const auto deltas = fixture.eventsOfType<Session::TextDelta>();
QCOMPARE(deltas.size(), 2);
QCOMPARE(deltas.at(0).text, QString("Hello"));
QCOMPARE(deltas.at(1).text, QString(" world"));
const auto thinking = fixture.eventsOfType<Session::ThinkingReceived>();
QCOMPARE(thinking.size(), 1);
QCOMPARE(thinking.at(0).text, QString("pondering"));
const QString turnId = fixture.eventsOfType<Session::TurnStarted>().at(0).turnId;
QVERIFY(!turnId.isEmpty());
QCOMPARE(deltas.at(0).turnId, turnId);
QCOMPARE(fixture.eventsOfType<Session::TurnCompleted>().at(0).turnId, turnId);
QCOMPARE(fixture.agent->prompts().size(), 1);
QCOMPARE(fixture.agent->prompts().at(0).size(), 1);
QCOMPARE(
fixture.agent->prompts().at(0).at(0).toObject().value("text").toString(), QString("hi"));
}
void QodeAssistTest::testAcpBackendStartsSessionInWorkingDirectory()
{
AcpBackendFixture fixture;
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.requestedCwd, Acp::agentWorkingDirectory());
QCOMPARE(fixture.agent->newSessionCwd(), Acp::agentWorkingDirectory());
QVERIFY(!fixture.agent->newSessionCwd().isEmpty());
QCOMPARE(fixture.agent->clientInfo().value("name").toString(), QString("QodeAssistTest"));
}
void QodeAssistTest::testAcpBackendAuthenticatesWhenTheAgentAsksForIt()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setRequireAuthentication(true); };
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 0);
QCOMPARE(fixture.agent->authMethodUsed(), QString("login"));
QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 2);
}
void QodeAssistTest::testAcpBackendReportsPromptFailure()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setPromptFailure("agent exploded"); };
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 1);
QCOMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 0);
QVERIFY(fixture.eventsOfType<Session::TurnFailed>().at(0).error.contains("agent exploded"));
QCOMPARE(
fixture.eventsOfType<Session::TurnFailed>().at(0).turnId,
fixture.eventsOfType<Session::TurnStarted>().at(0).turnId);
}
void QodeAssistTest::testAcpBackendCancelInterruptsTheTurn()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setHangOnPrompt(true);
agent->setReplyChunks({"partial"});
};
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TextDelta>().size(), 1);
fixture.backend.cancel();
QTRY_VERIFY(fixture.agent->wasCancelled());
QCOMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 0);
QCOMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 0);
}
void QodeAssistTest::testAcpBackendSendsAttachmentsAsEmbeddedResources()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); };
fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk"));
fixture.sendBlocks(
{Session::TextBlock{"summarise this"},
Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}});
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const auto resources = fixture.promptBlocksOfType("resource");
QCOMPARE(resources.size(), 1);
const QJsonObject resource = resources.at(0).value("resource").toObject();
QCOMPARE(resource.value("text").toString(), QString("remember the milk"));
QCOMPARE(resource.value("mimeType").toString(), QString("text/plain"));
QVERIFY(resource.value("uri").toString().endsWith("notes.txt"));
QCOMPARE(fixture.promptBlocksOfType("text").size(), 1);
}
void QodeAssistTest::testAcpBackendInlinesAttachmentsWithoutEmbeddedContextSupport()
{
AcpBackendFixture fixture;
fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk"));
fixture.sendBlocks(
{Session::TextBlock{"summarise this"},
Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}});
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.promptBlocksOfType("resource").size(), 0);
const auto texts = fixture.promptBlocksOfType("text");
QCOMPARE(texts.size(), 2);
const QString inlined = texts.at(1).value("text").toString();
QVERIFY(inlined.contains("notes.txt"));
QVERIFY(inlined.contains("remember the milk"));
}
void QodeAssistTest::testAcpBackendReportsAttachmentsItCannotLoad()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); };
fixture.sendBlocks(
{Session::TextBlock{"summarise this"},
Session::AttachmentBlock{"notes.txt", "missing.txt"}});
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.promptBlocksOfType("resource").size(), 0);
const auto texts = fixture.promptBlocksOfType("text");
QCOMPARE(texts.size(), 2);
QVERIFY(texts.at(1).value("text").toString().contains("notes.txt"));
}
void QodeAssistTest::testAcpBackendSendsImagesWhenTheAgentAcceptsThem()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsImages(true); };
fixture.storage.insert("shot_cd34.png", QByteArray("\x89PNG-bytes"));
fixture.sendBlocks(
{Session::TextBlock{"what is this"},
Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}});
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const auto images = fixture.promptBlocksOfType("image");
QCOMPARE(images.size(), 1);
QCOMPARE(images.at(0).value("mimeType").toString(), QString("image/png"));
QCOMPARE(
images.at(0).value("data").toString(),
QString::fromLatin1(QByteArray("\x89PNG-bytes").toBase64()));
}
void QodeAssistTest::testAcpBackendNamesImagesTheAgentCannotAccept()
{
AcpBackendFixture fixture;
fixture.storage.insert("shot_cd34.png", QByteArray("\x89PNG-bytes"));
fixture.sendBlocks(
{Session::TextBlock{"what is this"},
Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}});
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.promptBlocksOfType("image").size(), 0);
const auto texts = fixture.promptBlocksOfType("text");
QCOMPARE(texts.size(), 2);
QVERIFY(texts.at(1).value("text").toString().contains("shot.png"));
}
void QodeAssistTest::testAcpBackendFencesAttachmentsThatContainFences()
{
AcpBackendFixture fixture;
fixture.storage.insert("readme_ab12.md", QByteArray("intro\n```\ncode\n```\noutro"));
fixture.sendBlocks(
{Session::TextBlock{"read it"}, Session::AttachmentBlock{"readme.md", "readme_ab12.md"}});
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const auto texts = fixture.promptBlocksOfType("text");
QCOMPARE(texts.size(), 2);
const QString inlined = texts.at(1).value("text").toString();
QVERIFY(inlined.startsWith("File: readme.md\n````\n"));
QVERIFY(inlined.endsWith("\n````"));
QVERIFY(inlined.contains("```\ncode\n```"));
}
void QodeAssistTest::testAcpBackendTruncatesOversizedAttachments()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); };
const QByteArray huge(700 * 1024, 'x');
fixture.storage.insert("dump_ab12.log", huge);
fixture.sendBlocks(
{Session::TextBlock{"summarise"}, Session::AttachmentBlock{"dump.log", "dump_ab12.log"}});
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const auto resources = fixture.promptBlocksOfType("resource");
QCOMPARE(resources.size(), 1);
const QString text = resources.at(0).value("resource").toObject().value("text").toString();
QVERIFY(text.size() < huge.size());
QVERIFY(text.contains("truncated by QodeAssist"));
}
void QodeAssistTest::testAcpBackendRejectsAnEmptyTurn()
{
AcpBackendFixture fixture;
fixture.sendBlocks({Session::TextBlock{""}});
QCOMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 1);
QCOMPARE(fixture.eventsOfType<Session::TurnStarted>().size(), 0);
QVERIFY(fixture.agent == nullptr);
}
void QodeAssistTest::testAcpBackendSendsAttachmentsWithoutAnyMessageText()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); };
fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk"));
fixture.sendBlocks(
{Session::TextBlock{""}, Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}});
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 0);
QCOMPARE(fixture.promptBlocksOfType("resource").size(), 1);
}
namespace {
QJsonObject writeToolCall()
{
return QJsonObject{
{"toolCallId", QStringLiteral("call-1")},
{"title", QStringLiteral("Write main.cpp")},
{"kind", QStringLiteral("edit")},
{"status", QStringLiteral("pending")}};
}
QJsonArray writePermissionOptions()
{
return QJsonArray{
QJsonObject{
{"optionId", QStringLiteral("allow")},
{"name", QStringLiteral("Allow")},
{"kind", QStringLiteral("allow_once")}},
QJsonObject{
{"optionId", QStringLiteral("deny")},
{"name", QStringLiteral("Deny")},
{"kind", QStringLiteral("reject_once")}}};
}
} // namespace
void QodeAssistTest::testAcpBackendRaisesAgentPermissionRequests()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
};
fixture.send("edit main.cpp");
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 1);
const Session::PermissionRequested request
= fixture.eventsOfType<Session::PermissionRequested>().at(0);
QCOMPARE(request.turnId, fixture.eventsOfType<Session::TurnStarted>().at(0).turnId);
QVERIFY(!request.requestId.isEmpty());
QCOMPARE(request.toolCallId, QString("call-1"));
QCOMPARE(request.title, QString("Write main.cpp"));
QCOMPARE(request.toolKind, QString("edit"));
QCOMPARE(request.options.size(), 2);
QCOMPARE(request.options.at(0), (Session::PermissionOption{"allow", "Allow", "allow_once"}));
QCOMPARE(request.options.at(1), (Session::PermissionOption{"deny", "Deny", "reject_once"}));
QCOMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 0);
QVERIFY(fixture.agent->permissionOutcomes().isEmpty());
}
void QodeAssistTest::testAcpBackendSendsThePermissionAnswerToTheAgent()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
};
fixture.send("edit main.cpp");
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 1);
const QString requestId = fixture.eventsOfType<Session::PermissionRequested>().at(0).requestId;
fixture.backend.respondPermission(requestId, "allow");
QTRY_COMPARE(fixture.agent->permissionOutcomes().size(), 1);
QCOMPARE(
fixture.agent->permissionOutcomes().at(0).value("outcome").toString(), QString("selected"));
QCOMPARE(fixture.agent->permissionOutcomes().at(0).value("optionId").toString(), QString("allow"));
const auto resolutions = fixture.eventsOfType<Session::PermissionResolved>();
QCOMPARE(resolutions.size(), 1);
QCOMPARE(resolutions.at(0).requestId, requestId);
QCOMPARE(resolutions.at(0).optionId, QString("allow"));
QVERIFY(!resolutions.at(0).cancelled);
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
}
void QodeAssistTest::testAcpBackendCancelsOutstandingPermissionRequests()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
};
fixture.send("edit main.cpp");
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 1);
const QString requestId = fixture.eventsOfType<Session::PermissionRequested>().at(0).requestId;
fixture.backend.cancel();
QTRY_COMPARE(fixture.agent->permissionOutcomes().size(), 1);
QCOMPARE(
fixture.agent->permissionOutcomes().at(0).value("outcome").toString(), QString("cancelled"));
const auto resolutions = fixture.eventsOfType<Session::PermissionResolved>();
QCOMPARE(resolutions.size(), 1);
QCOMPARE(resolutions.at(0).requestId, requestId);
QVERIFY(resolutions.at(0).cancelled);
QVERIFY(resolutions.at(0).optionId.isEmpty());
QTRY_VERIFY(fixture.agent->wasCancelled());
QCOMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 0);
}
namespace {
class FakeKnowledgeService : public Acp::AgentKnowledgeService
{
public:
QString start() override
{
++startCount;
running = true;
return url;
}
void stop() override
{
++stopCount;
running = false;
}
QString serverName() const override { return QStringLiteral("qodeassist"); }
QString url = QStringLiteral("http://127.0.0.1:54321/mcp");
int startCount = 0;
int stopCount = 0;
bool running = false;
};
} // namespace
namespace {
class FakeGatedTool : public ::LLMQore::BaseTool
{
public:
FakeGatedTool(QString toolId, ::LLMQore::ToolSafety safety, QObject *parent = nullptr)
: ::LLMQore::BaseTool(parent)
, m_id(std::move(toolId))
, m_safety(safety)
{}
QString id() const override { return m_id; }
QString displayName() const override { return m_id; }
QString description() const override { return m_id; }
QJsonObject parametersSchema() const override { return {}; }
::LLMQore::ToolSafety safety() const override { return m_safety; }
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &) override
{
++executions;
QPromise<LLMQore::ToolResult> promise;
promise.start();
promise.addResult(LLMQore::ToolResult::text(QStringLiteral("ran ") + m_id));
promise.finish();
return promise.future();
}
int executions = 0;
private:
QString m_id;
::LLMQore::ToolSafety m_safety;
};
} // namespace
void QodeAssistTest::testAcpBackendSeedsAHandoverSummaryOnce()
{
AcpBackendFixture fixture;
fixture.backend.setHandoverSummary("Earlier the user asked about parsing.");
fixture.send("carry on");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const QList<QJsonObject> first = fixture.promptBlocksOfType("text");
QCOMPARE(first.size(), 2);
QVERIFY(first.at(0).value("text").toString().contains("Earlier the user asked about parsing."));
QVERIFY(first.at(0).value("text").toString().contains("summary"));
QCOMPARE(first.at(1).value("text").toString(), QString("carry on"));
fixture.send("and now this");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 2);
const QList<QJsonObject> second = fixture.promptBlocksOfType("text");
QCOMPARE(second.size(), 1);
QCOMPARE(second.at(0).value("text").toString(), QString("and now this"));
}
void QodeAssistTest::testAcpBackendDropsAHandoverSummaryAfterACancelledTurn()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setHangOnPrompt(true); };
fixture.backend.setHandoverSummary("Earlier context.");
fixture.send("carry on");
QTRY_COMPARE(fixture.agent->prompts().size(), 1);
QVERIFY(
fixture.agent->prompts().at(0).at(0).toObject().value("text").toString().contains(
"Earlier context."));
fixture.backend.cancel();
fixture.configure = [](FakeAcpAgent *) {};
fixture.send("actually this");
QTRY_COMPARE(fixture.agent->prompts().size(), 2);
const QJsonArray second = fixture.agent->prompts().at(1);
QCOMPARE(second.size(), 1);
QCOMPARE(second.at(0).toObject().value("text").toString(), QString("actually this"));
}
void QodeAssistTest::testAcpBackendDropsAHandoverSummaryWithTheConversation()
{
AcpBackendFixture fixture;
fixture.backend.setHandoverSummary("stale summary");
fixture.backend.clearToolSession(QString());
fixture.send("fresh start");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const QList<QJsonObject> texts = fixture.promptBlocksOfType("text");
QCOMPARE(texts.size(), 1);
QCOMPARE(texts.at(0).value("text").toString(), QString("fresh start"));
}
void QodeAssistTest::testToolSafetyDefaultsToMutating()
{
FakeGatedTool undeclared("undeclared", ::LLMQore::ToolSafety::Mutating);
QCOMPARE(undeclared.safety(), ::LLMQore::ToolSafety::Mutating);
QCOMPARE(Tools::ReadFileTool().safety(), ::LLMQore::ToolSafety::ReadOnly);
QCOMPARE(Tools::GetIssuesListTool().safety(), ::LLMQore::ToolSafety::ReadOnly);
QCOMPARE(Tools::ListOpenEditorsTool().safety(), ::LLMQore::ToolSafety::ReadOnly);
QCOMPARE(Tools::EditFileTool().safety(), ::LLMQore::ToolSafety::Mutating);
QCOMPARE(Tools::CreateNewFileTool().safety(), ::LLMQore::ToolSafety::Mutating);
QCOMPARE(Tools::ExecuteTerminalCommandTool().safety(), ::LLMQore::ToolSafety::Mutating);
QCOMPARE(Tools::BuildProjectTool().safety(), ::LLMQore::ToolSafety::Mutating);
}
void QodeAssistTest::testToolsManagerWithoutAGateRunsImmediately()
{
::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI);
auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager);
manager.addTool(tool);
manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{});
QTRY_COMPARE(tool->executions, 1);
}
void QodeAssistTest::testToolsManagerGateCanDeclineAToolCall()
{
::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI);
auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager);
manager.addTool(tool);
QStringList gated;
manager.setExecutionGate(
[&gated](
const QString &, const QString &, const QString &toolName, const QJsonObject &) {
gated.append(toolName);
QPromise<bool> promise;
promise.start();
promise.addResult(false);
promise.finish();
return promise.future();
});
QString resultText;
QObject::connect(
&manager,
&::LLMQore::ToolsManager::toolExecutionResult,
&manager,
[&resultText](const QString &, const QString &, const QString &, const QString &result) {
resultText = result;
});
manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{});
QTRY_VERIFY(!resultText.isEmpty());
QCOMPARE(gated, QStringList{"edit_thing"});
QCOMPARE(tool->executions, 0);
QVERIFY2(resultText.contains("declined"), qPrintable(resultText));
}
void QodeAssistTest::testToolsManagerGateCanAllowAToolCall()
{
::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI);
auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager);
manager.addTool(tool);
manager.setExecutionGate(
[](const QString &, const QString &, const QString &, const QJsonObject &) {
QPromise<bool> promise;
promise.start();
promise.addResult(true);
promise.finish();
return promise.future();
});
manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{});
QTRY_COMPARE(tool->executions, 1);
}
void QodeAssistTest::testToolsManagerGateResumesTheQueueAfterADenial()
{
::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI);
auto *denied = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager);
auto *allowed = new FakeGatedTool("read_thing", ::LLMQore::ToolSafety::ReadOnly, &manager);
manager.addTool(denied);
manager.addTool(allowed);
manager.setExecutionGate(
[](const QString &, const QString &, const QString &toolName, const QJsonObject &) {
QPromise<bool> promise;
promise.start();
promise.addResult(toolName != QLatin1String("edit_thing"));
promise.finish();
return promise.future();
});
bool finished = false;
QObject::connect(
&manager,
&::LLMQore::ToolsManager::toolExecutionComplete,
&manager,
[&finished](const QString &, const QHash<QString, LLMQore::ToolResult> &) {
finished = true;
});
manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{});
manager.executeToolCall("req-1", "call-2", "read_thing", QJsonObject{});
QTRY_VERIFY(finished);
QCOMPARE(denied->executions, 0);
QCOMPARE(allowed->executions, 1);
}
void QodeAssistTest::testKnowledgeServerExposesOnlyReadOnlyTools()
{
const QStringList exposed = Mcp::AgentKnowledgeServer::toolIds();
QCOMPARE(
QSet<QString>(exposed.cbegin(), exposed.cend()),
(QSet<QString>{
"list_open_editors", "get_editor_selection", "get_project_model", "get_issues_list"}));
const QStringList mutating{
Tools::EditFileTool().id(),
Tools::CreateNewFileTool().id(),
Tools::ExecuteTerminalCommandTool().id(),
Tools::BuildProjectTool().id()};
for (const QString &tool : mutating)
QVERIFY2(!exposed.contains(tool), qPrintable(tool));
}
void QodeAssistTest::testKnowledgeServerBindsLoopbackOnAnEphemeralPort()
{
Mcp::AgentKnowledgeServer server;
const QString url = server.start();
QVERIFY(!url.isEmpty());
QVERIFY(server.runningPort() != 0);
const QUrl parsed(url);
QVERIFY(QHostAddress(parsed.host()).isLoopback());
QCOMPARE(parsed.port(), int(server.runningPort()));
QVERIFY2(
parsed.path().length() > QStringLiteral("/mcp/").length(),
qPrintable(parsed.path()));
QCOMPARE(server.start(), url);
server.stop();
QCOMPARE(server.runningPort(), quint16(0));
server.stop();
}
void QodeAssistTest::testAcpBackendOffersKnowledgeToHttpCapableAgents()
{
AcpBackendFixture fixture;
FakeKnowledgeService knowledge;
fixture.backend.setKnowledgeService(&knowledge);
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); };
fixture.send("what am I looking at");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(knowledge.startCount, 1);
QVERIFY(knowledge.running);
const QJsonArray offered = fixture.agent->offeredMcpServers();
QCOMPARE(offered.size(), 1);
QCOMPARE(offered.at(0).toObject().value("name").toString(), QString("qodeassist"));
QCOMPARE(offered.at(0).toObject().value("type").toString(), QString("http"));
QCOMPARE(offered.at(0).toObject().value("url").toString(), knowledge.url);
}
void QodeAssistTest::testAcpBackendWithholdsKnowledgeFromAgentsWithoutHttpMcp()
{
AcpBackendFixture fixture;
FakeKnowledgeService knowledge;
fixture.backend.setKnowledgeService(&knowledge);
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(false); };
fixture.send("what am I looking at");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(knowledge.startCount, 0);
QVERIFY(!knowledge.running);
QVERIFY(fixture.agent->offeredMcpServers().isEmpty());
}
void QodeAssistTest::testAcpBackendStopsTheKnowledgeServerWithTheSession()
{
AcpBackendFixture fixture;
FakeKnowledgeService knowledge;
fixture.backend.setKnowledgeService(&knowledge);
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); };
fixture.send("hello");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QVERIFY(knowledge.running);
fixture.backend.clearToolSession(QString());
QCOMPARE(knowledge.stopCount, 1);
QVERIFY(!knowledge.running);
}
void QodeAssistTest::testAcpBackendSurvivesAKnowledgeServerThatWillNotStart()
{
AcpBackendFixture fixture;
FakeKnowledgeService knowledge;
knowledge.url.clear();
fixture.backend.setKnowledgeService(&knowledge);
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); };
fixture.send("hello");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(knowledge.startCount, 1);
QVERIFY(fixture.agent->offeredMcpServers().isEmpty());
QCOMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 0);
}
void QodeAssistTest::testAcpBackendResumesAPersistedSession()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); };
fixture.backend.resumeSession("previous-session");
QVERIFY(fixture.backend.isResumePending());
fixture.send("carry on");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.agent->loadedSessionId(), QString("previous-session"));
QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0);
QCOMPARE(fixture.backend.acpSessionId(), QString("previous-session"));
QVERIFY(!fixture.backend.isResumePending());
QCOMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 0);
}
void QodeAssistTest::testAcpBackendReportsAnUnresumableSession()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setSupportsLoadSession(true);
agent->setLoadSessionError("session expired");
};
QStringList unavailable;
QObject::connect(
&fixture.backend,
&Acp::AcpChatBackend::agentSessionUnavailable,
&fixture.backend,
[&unavailable](const QString &reason) { unavailable.append(reason); });
fixture.backend.resumeSession("previous-session");
fixture.send("carry on");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 1);
QCOMPARE(unavailable.size(), 1);
QVERIFY(unavailable.at(0).contains("session expired"));
QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0);
QVERIFY(fixture.backend.acpSessionId().isEmpty());
QVERIFY(!fixture.backend.isResumePending());
QVERIFY(fixture.eventsOfType<Session::TurnFailed>().at(0).error.contains("read-only"));
}
void QodeAssistTest::testAcpBackendRefusesToResumeWithoutAgentSupport()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(false); };
QStringList unavailable;
QObject::connect(
&fixture.backend,
&Acp::AcpChatBackend::agentSessionUnavailable,
&fixture.backend,
[&unavailable](const QString &reason) { unavailable.append(reason); });
fixture.backend.resumeSession("previous-session");
fixture.send("carry on");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 1);
QCOMPARE(unavailable.size(), 1);
QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/load")), 0);
QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0);
QVERIFY(fixture.backend.acpSessionId().isEmpty());
}
void QodeAssistTest::testAcpBackendStartsFreshAfterAFailedResume()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setSupportsLoadSession(true);
agent->setLoadSessionError("session expired");
};
fixture.backend.resumeSession("previous-session");
fixture.send("carry on");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 1);
fixture.backend.startFreshSession();
fixture.send("start over");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1);
QCOMPARE(fixture.backend.acpSessionId(), QString("fake-session"));
QCOMPARE(fixture.agent->prompts().size(), 1);
QCOMPARE(
fixture.agent->prompts().at(0).at(0).toObject().value("text").toString(),
QString("start over"));
}
void QodeAssistTest::testAgentBindingKeepsTheResumeTargetBeforeTheFirstTurn()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); };
QVERIFY(fixture.backend.bindingSessionId().isEmpty());
fixture.backend.resumeSession("sess-from-file");
QCOMPARE(fixture.backend.bindingSessionId(), QString("sess-from-file"));
QVERIFY(fixture.backend.acpSessionId().isEmpty());
fixture.send("carry on");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.backend.acpSessionId(), QString("sess-from-file"));
QCOMPARE(fixture.backend.bindingSessionId(), QString("sess-from-file"));
}
void QodeAssistTest::testAcpBackendCarriesTheLiveSessionIntoAResume()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); };
fixture.send("hello");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.backend.acpSessionId(), QString("fake-session"));
fixture.backend.setChatFilePath("/somewhere/else.json");
QCOMPARE(fixture.backend.bindingSessionId(), QString("fake-session"));
fixture.backend.clearToolSession(QString());
QVERIFY(fixture.backend.bindingSessionId().isEmpty());
}
void QodeAssistTest::testAcpBackendEstablishesTheSessionOnlyOnce()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) { agent->setHangOnNewSession(true); };
fixture.send("first");
QTRY_COMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1);
fixture.backend.cancel();
fixture.send("second");
QTest::qWait(50);
QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1);
QCOMPARE(fixture.agent->prompts().size(), 0);
}
void QodeAssistTest::testAgentBindingRejectsMalformedRecords()
{
const auto rejected = [](const QJsonValue &value) {
QString error;
const Acp::AgentBinding binding = Acp::AgentBinding::fromJson(value, &error);
return binding.isEmpty() && !error.isEmpty();
};
QVERIFY(rejected(QJsonValue(QStringLiteral("claude"))));
QVERIFY(rejected(QJsonValue(42)));
QVERIFY(rejected(QJsonObject{{"agentId", 7}}));
QVERIFY(rejected(QJsonObject{{"agentId", "claude"}, {"sessionId", QJsonArray{}}}));
QVERIFY(rejected(QJsonObject{{"sessionId", "orphan"}}));
QVERIFY(rejected(QJsonObject{{"agentId", QString(4096, QChar('a'))}}));
QVERIFY(rejected(QJsonObject{{"agentId", QStringLiteral("claude")}}));
QString error;
QVERIFY(Acp::AgentBinding::fromJson(QJsonValue(), &error).isEmpty());
QVERIFY(error.isEmpty());
const Acp::AgentBinding good = Acp::AgentBinding::fromJson(
QJsonObject{{"agentId", "claude"}, {"sessionId", "sess-1"}}, &error);
QCOMPARE(good, (Acp::AgentBinding{"claude", "sess-1"}));
QVERIFY(error.isEmpty());
}
void QodeAssistTest::testMalformedAgentBindingLeavesTheChatUnbound()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
const QString filePath = dir.filePath("broken_agent.json");
Session::Message user;
user.role = Session::MessageRole::User;
user.blocks = {Session::TextBlock{"hello"}};
Session::ConversationHistory history;
history.append(user);
QVERIFY(Chat::ChatFileStore::saveToFile(history, Acp::AgentBinding{}, filePath).success);
QFile file(filePath);
QVERIFY(file.open(QIODevice::ReadWrite));
QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
root["agent"] = QStringLiteral("claude");
file.resize(0);
file.write(QJsonDocument(root).toJson());
file.close();
Session::ConversationHistory reloaded;
Acp::AgentBinding binding;
const Chat::SerializationResult result
= Chat::ChatFileStore::loadFromFile(reloaded, binding, filePath);
QVERIFY(result.success);
QVERIFY(binding.isEmpty());
QVERIFY(!result.warningMessage.isEmpty());
}
void QodeAssistTest::testAgentBindingRoundTripsThroughTheChatFile()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
const QString filePath = dir.filePath("agent_chat.json");
Session::Message user;
user.role = Session::MessageRole::User;
user.blocks = {Session::TextBlock{"hello agent"}};
Session::ConversationHistory history;
history.append(user);
const Acp::AgentBinding binding{"claude-code-acp", "sess-42"};
QVERIFY(Chat::ChatFileStore::saveToFile(history, binding, filePath).success);
Session::ConversationHistory reloaded;
Acp::AgentBinding reloadedBinding;
QVERIFY(Chat::ChatFileStore::loadFromFile(reloaded, reloadedBinding, filePath).success);
QCOMPARE(reloaded, history);
QCOMPARE(reloadedBinding, binding);
}
void QodeAssistTest::testChatFileWithoutAnAgentLoadsUnbound()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
const QString filePath = dir.filePath("llm_chat.json");
Session::Message user;
user.role = Session::MessageRole::User;
user.blocks = {Session::TextBlock{"hello"}};
Session::ConversationHistory history;
history.append(user);
QVERIFY(Chat::ChatFileStore::saveToFile(history, Acp::AgentBinding{}, filePath).success);
QFile file(filePath);
QVERIFY(file.open(QIODevice::ReadOnly));
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
QVERIFY(!root.contains("agent"));
file.close();
Session::ConversationHistory reloaded;
Acp::AgentBinding reloadedBinding;
QVERIFY(Chat::ChatFileStore::loadFromFile(reloaded, reloadedBinding, filePath).success);
QVERIFY(reloadedBinding.isEmpty());
}
void QodeAssistTest::testAcpBackendMapsToolCallLifecycle()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setToolCallUpdates(
{QJsonObject{
{"toolCallId", QStringLiteral("call-1")},
{"title", QStringLiteral("Edit main.cpp")},
{"kind", QStringLiteral("edit")},
{"status", QStringLiteral("pending")}},
QJsonObject{
{"toolCallId", QStringLiteral("call-1")},
{"status", QStringLiteral("completed")},
{"locations",
QJsonArray{QJsonObject{{"path", QStringLiteral("/p/main.cpp")}, {"line", 12}}}},
{"content",
QJsonArray{
QJsonObject{
{"type", QStringLiteral("diff")},
{"path", QStringLiteral("/p/main.cpp")},
{"oldText", QStringLiteral("int a;")},
{"newText", QStringLiteral("int b;")}}}}}});
};
fixture.send("edit it");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const auto updates = fixture.eventsOfType<Session::ToolCallUpdated>();
QCOMPARE(updates.size(), 2);
QCOMPARE(updates.at(0).toolId, QString("call-1"));
QCOMPARE(updates.at(0).name, QString("Edit main.cpp"));
QCOMPARE(updates.at(0).kind, QString("edit"));
QCOMPARE(updates.at(0).status, QString("pending"));
QVERIFY(updates.at(0).details.isEmpty());
QVERIFY(updates.at(0).fromAgent);
QVERIFY(updates.at(1).fromAgent);
QCOMPARE(updates.at(1).status, QString("completed"));
QCOMPARE(updates.at(1).name, QString("Edit main.cpp"));
const QJsonArray locations = updates.at(1).details.value("locations").toArray();
QCOMPARE(locations.size(), 1);
QCOMPARE(locations.at(0).toObject().value("path").toString(), QString("/p/main.cpp"));
QCOMPARE(locations.at(0).toObject().value("line").toInt(), 12);
const QJsonArray diffs = updates.at(1).details.value("diffs").toArray();
QCOMPARE(diffs.size(), 1);
QCOMPARE(diffs.at(0).toObject().value("oldText").toString(), QString("int a;"));
QCOMPARE(diffs.at(0).toObject().value("newText").toString(), QString("int b;"));
}
void QodeAssistTest::testAcpBackendMapsPlanUpdates()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setPlanUpdates(
{QJsonArray{
QJsonObject{
{"content", QStringLiteral("Read the file")},
{"priority", QStringLiteral("high")},
{"status", QStringLiteral("in_progress")}},
QJsonObject{
{"content", QStringLiteral("Apply the fix")},
{"priority", QStringLiteral("medium")},
{"status", QStringLiteral("pending")}}},
QJsonArray{
QJsonObject{
{"content", QStringLiteral("Read the file")},
{"priority", QStringLiteral("high")},
{"status", QStringLiteral("completed")}},
QJsonObject{
{"content", QStringLiteral("Apply the fix")},
{"priority", QStringLiteral("medium")},
{"status", QStringLiteral("in_progress")}}}});
};
fixture.send("fix it");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const auto plans = fixture.eventsOfType<Session::PlanUpdated>();
QCOMPARE(plans.size(), 2);
QCOMPARE(plans.at(0).entries.size(), 2);
QCOMPARE(
plans.at(0).entries.at(0),
(Session::PlanEntry{"Read the file", "high", "in_progress"}));
QCOMPARE(
plans.at(1).entries.at(0), (Session::PlanEntry{"Read the file", "high", "completed"}));
}
void QodeAssistTest::testAcpBackendReportsUsageFromThePromptResult()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setPromptUsage(
QJsonObject{
{"cachedReadTokens", 23119},
{"cachedWriteTokens", 12271},
{"inputTokens", 2},
{"outputTokens", 13},
{"totalTokens", 35405}});
};
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const auto usage = fixture.eventsOfType<Session::UsageReported>();
QCOMPARE(usage.size(), 1);
QCOMPARE(usage.at(0).usage.promptTokens, 2);
QCOMPARE(usage.at(0).usage.completionTokens, 13);
QCOMPARE(usage.at(0).usage.cachedPromptTokens, 23119);
QCOMPARE(usage.at(0).usage.reasoningTokens, 0);
}
void QodeAssistTest::testAcpBackendIgnoresTheCumulativeContextGauge()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setContextGauge(
QJsonObject{
{"size", 967000},
{"used", 35395},
{"cost", QJsonObject{{"amount", 0.0807}, {"currency", QStringLiteral("USD")}}}});
};
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.eventsOfType<Session::UsageReported>().size(), 0);
}
void QodeAssistTest::testAcpBackendForwardsTheAgentSuggestedTitle()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setSuggestedTitle(" Refactor\nthe parser ");
};
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QTRY_COMPARE(fixture.eventsOfType<Session::SessionInfo>().size(), 1);
QCOMPARE(
fixture.eventsOfType<Session::SessionInfo>().at(0).title,
QString("Refactor the parser"));
}
void QodeAssistTest::testAcpBackendDeclinesAmbiguousPermissionOptions()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setPermissionRequest(
writeToolCall(),
QJsonArray{
QJsonObject{
{"optionId", QStringLiteral("x")},
{"name", QStringLiteral("Yes, and don't ask again")},
{"kind", QStringLiteral("allow_always")}},
QJsonObject{
{"optionId", QStringLiteral("x")},
{"name", QStringLiteral("No")},
{"kind", QStringLiteral("reject_once")}}});
};
fixture.send("edit main.cpp");
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionResolved>().size(), 1);
const auto requests = fixture.eventsOfType<Session::PermissionRequested>();
QCOMPARE(requests.size(), 1);
QVERIFY(requests.at(0).options.isEmpty());
QVERIFY(fixture.eventsOfType<Session::PermissionResolved>().at(0).cancelled);
QTRY_COMPARE(fixture.agent->permissionOutcomes().size(), 1);
QCOMPARE(
fixture.agent->permissionOutcomes().at(0).value("outcome").toString(), QString("cancelled"));
}
void QodeAssistTest::testAcpBackendClampsOversizedPermissionText()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
QJsonObject toolCall = writeToolCall();
toolCall["title"] = QString(5000, QChar('A'));
agent->setPermissionRequest(toolCall, writePermissionOptions());
};
fixture.send("edit main.cpp");
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 1);
const Session::PermissionRequested request
= fixture.eventsOfType<Session::PermissionRequested>().at(0);
QVERIFY(request.title.size() < 5000);
QVERIFY(request.title.endsWith(QChar(0x2026)));
QCOMPARE(request.options.size(), 2);
}
void QodeAssistTest::testAcpBackendCancelsPermissionsWhenTheTurnCompletes()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
agent->setFinishPromptWithoutWaiting(true);
};
fixture.send("edit main.cpp");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
const auto resolutions = fixture.eventsOfType<Session::PermissionResolved>();
QCOMPARE(resolutions.size(), 1);
QVERIFY(resolutions.at(0).cancelled);
const QString requestId = fixture.eventsOfType<Session::PermissionRequested>().at(0).requestId;
QVERIFY(!fixture.backend.respondPermission(requestId, "allow"));
}
void QodeAssistTest::testAcpBackendCancelsPermissionRequestsWhenTheTurnFails()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
};
fixture.send("edit main.cpp");
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 1);
fixture.backend.clearToolSession(QString());
QCOMPARE(fixture.eventsOfType<Session::PermissionResolved>().size(), 1);
QVERIFY(fixture.eventsOfType<Session::PermissionResolved>().at(0).cancelled);
}
namespace {
class LlmBackendFixture
{
public:
LlmBackendFixture()
: backend(&promptProvider)
{
backend.setProviderResolver([this](const QString &) { return &provider; });
QObject::connect(
&backend,
&Session::ChatBackend::sessionEvent,
&backend,
[this](const Session::SessionEvent &event) { events.append(event); });
Session::Message user;
user.role = Session::MessageRole::User;
user.id = "u1";
user.blocks = {Session::TextBlock{"hello"}};
history.append(user);
}
QString send()
{
Session::TurnRequest request;
request.userBlocks = {Session::TextBlock{"hello"}};
request.history = &history;
backend.sendTurn(request);
const auto started = eventsOfType<Session::TurnStarted>();
return started.isEmpty() ? QString() : started.constLast().turnId;
}
template<typename T>
QList<T> eventsOfType() const
{
QList<T> result;
for (const Session::SessionEvent &event : events) {
if (const auto *typed = std::get_if<T>(&event))
result.append(*typed);
}
return result;
}
FakeChatPromptProvider promptProvider;
FakeLlmProvider provider;
Session::ConversationHistory history;
QList<Session::SessionEvent> events;
Chat::LlmChatBackend backend;
};
} // namespace
void QodeAssistTest::testTurnLedgerTracksTheActiveTurn()
{
Session::TurnLedger ledger;
QVERIFY(!ledger.hasActiveTurn());
QVERIFY(!ledger.isActiveTurn(QString()));
QVERIFY(!ledger.isActiveTurn("t1"));
QCOMPARE(ledger.beginTurn("t1"), QString("t1"));
QVERIFY(ledger.hasActiveTurn());
QVERIFY(ledger.isActiveTurn("t1"));
QVERIFY(!ledger.isActiveTurn("t2"));
QVERIFY(!ledger.isActiveTurn(QString()));
ledger.endTurn();
QVERIFY(!ledger.hasActiveTurn());
QVERIFY(!ledger.isActiveTurn("t1"));
}
void QodeAssistTest::testTurnLedgerDrainsPermissionsOnTurnEnd()
{
Session::TurnLedger ledger;
ledger.beginTurn("t1");
QStringList resolved;
int cancelled = 0;
const QString first = ledger.registerPermission(
[&resolved](const QString &optionId) { resolved.append(optionId); },
[&cancelled] { ++cancelled; });
const QString second = ledger.registerPermission(
[&resolved](const QString &optionId) { resolved.append(optionId); },
[&cancelled] { ++cancelled; });
QVERIFY(first.startsWith("perm-"));
QVERIFY(second.startsWith("perm-"));
QVERIFY(first != second);
QCOMPARE(ledger.pendingPermissionCount(), 2);
QVERIFY(ledger.resolvePermission(first, "allow_once"));
QCOMPARE(resolved, QStringList{"allow_once"});
QVERIFY(!ledger.resolvePermission(first, "allow_once"));
QVERIFY(!ledger.hasPendingPermission(first));
QVERIFY(ledger.hasPendingPermission(second));
const QStringList drained = ledger.endTurn();
QCOMPARE(drained, QStringList{second});
QCOMPARE(cancelled, 1);
QCOMPARE(ledger.pendingPermissionCount(), 0);
QVERIFY(!ledger.resolvePermission(second, "allow_once"));
QCOMPARE(resolved, QStringList{"allow_once"});
}
void QodeAssistTest::testLlmBackendStreamsThroughAFakeClient()
{
LlmBackendFixture fixture;
const QString turnId = fixture.send();
QCOMPARE(turnId, QString("fake-req-1"));
QVERIFY(fixture.provider.fakeClient()->lastPayload.contains("model"));
QVERIFY(fixture.provider.fakeClient()->lastPayload.contains("stream"));
emit fixture.provider.fakeClient()->chunkReceived(turnId, "hel");
emit fixture.provider.fakeClient()->chunkReceived(turnId, "lo");
emit fixture.provider.fakeClient()->requestCompleted(turnId, "hello");
const auto deltas = fixture.eventsOfType<Session::TextDelta>();
QCOMPARE(deltas.size(), 2);
QCOMPARE(deltas.at(0).text, QString("hel"));
QCOMPARE(deltas.at(1).text, QString("lo"));
QCOMPARE(deltas.at(0).turnId, turnId);
QCOMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.eventsOfType<Session::TurnCompleted>().at(0).turnId, turnId);
QCOMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 0);
}
void QodeAssistTest::testRenderHistoryKeepsToolExchangePairs()
{
LlmBackendFixture fixture;
Session::Message assistant;
assistant.role = Session::MessageRole::Assistant;
assistant.id = "r0";
assistant.blocks
= {Session::ToolCallBlock{
"t1",
"read_file",
QJsonObject{{"path", "main.cpp"}},
"int main() {}",
{},
"completed"},
Session::ToolCallBlock{
"call-9",
"Edit main.cpp",
QJsonObject{},
"done",
"edit",
"completed",
QJsonObject{},
true},
Session::TextBlock{"the answer"}};
fixture.history.append(assistant);
fixture.send();
QVERIFY(fixture.provider.lastContext.history.has_value());
const QVector<LLMCore::Message> &messages = *fixture.provider.lastContext.history;
QCOMPARE(messages.size(), 4);
QCOMPARE(messages.at(0).role, QString("user"));
QCOMPARE(messages.at(1).role, QString("assistant"));
QCOMPARE(messages.at(1).toolCalls.size(), 1);
QCOMPARE(messages.at(1).toolCalls.at(0).id, QString("t1"));
QCOMPARE(messages.at(1).toolCalls.at(0).name, QString("read_file"));
QCOMPARE(
messages.at(1).toolCalls.at(0).arguments, (QJsonObject{{"path", "main.cpp"}}));
QCOMPARE(messages.at(2).role, QString("tool"));
QCOMPARE(messages.at(2).toolCallId, QString("t1"));
QCOMPARE(messages.at(2).content, QString("int main() {}"));
QCOMPARE(messages.at(3).role, QString("assistant"));
QCOMPARE(messages.at(3).content, QString("the answer"));
}
void QodeAssistTest::testLlmBackendIgnoresEventsFromOtherRequests()
{
LlmBackendFixture fixture;
const QString turnId = fixture.send();
QVERIFY(!turnId.isEmpty());
emit fixture.provider.fakeClient()->chunkReceived("stale-req", "ignored");
QCOMPARE(fixture.eventsOfType<Session::TextDelta>().size(), 0);
fixture.backend.cancel();
emit fixture.provider.fakeClient()->chunkReceived(turnId, "late");
QCOMPARE(fixture.eventsOfType<Session::TextDelta>().size(), 0);
QCOMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 0);
}
void QodeAssistTest::testLlmBackendPermissionRoundTrip()
{
LlmBackendFixture fixture;
auto *tool = new FakeMutatingTool(&fixture.backend);
fixture.provider.toolsManager()->addTool(tool);
const QString turnId = fixture.send();
fixture.provider.toolsManager()->executeToolCall(turnId, "t1", "fake_write", {});
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 1);
const Session::PermissionRequested request
= fixture.eventsOfType<Session::PermissionRequested>().at(0);
QCOMPARE(request.turnId, turnId);
QVERIFY(request.requestId.startsWith("perm-"));
QCOMPARE(request.toolKind, QString("fake_write"));
QCOMPARE(request.options.size(), 3);
QVERIFY(fixture.backend.respondPermission(request.requestId, "allow_once"));
QTRY_COMPARE(tool->executions, 1);
auto resolutions = fixture.eventsOfType<Session::PermissionResolved>();
QCOMPARE(resolutions.size(), 1);
QCOMPARE(resolutions.at(0).requestId, request.requestId);
QCOMPARE(resolutions.at(0).optionId, QString("allow_once"));
QVERIFY(!resolutions.at(0).cancelled);
fixture.provider.toolsManager()->executeToolCall(turnId, "t2", "fake_write", {});
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 2);
const QString second = fixture.eventsOfType<Session::PermissionRequested>().at(1).requestId;
QVERIFY(second != request.requestId);
QVERIFY(fixture.backend.respondPermission(second, "reject_once"));
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionResolved>().size(), 2);
QCOMPARE(tool->executions, 1);
QTRY_VERIFY(
!fixture.eventsOfType<Session::ToolCallUpdated>().isEmpty()
&& fixture.eventsOfType<Session::ToolCallUpdated>().constLast().toolId == QString("t2"));
const Session::ToolCallUpdated declined
= fixture.eventsOfType<Session::ToolCallUpdated>().constLast();
QCOMPARE(declined.toolId, QString("t2"));
QCOMPARE(declined.status, QString("failed"));
QVERIFY(declined.result.startsWith("Error: "));
}
void QodeAssistTest::testLlmBackendDrainsPermissionsWhenTheTurnEnds()
{
LlmBackendFixture fixture;
auto *tool = new FakeMutatingTool(&fixture.backend);
fixture.provider.toolsManager()->addTool(tool);
QString turnId = fixture.send();
fixture.provider.toolsManager()->executeToolCall(turnId, "t1", "fake_write", {});
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 1);
QString requestId = fixture.eventsOfType<Session::PermissionRequested>().at(0).requestId;
emit fixture.provider.fakeClient()->requestCompleted(turnId, "done");
auto resolutions = fixture.eventsOfType<Session::PermissionResolved>();
QCOMPARE(resolutions.size(), 1);
QCOMPARE(resolutions.at(0).requestId, requestId);
QVERIFY(resolutions.at(0).cancelled);
QVERIFY(!fixture.backend.respondPermission(requestId, "allow_once"));
QCOMPARE(tool->executions, 0);
fixture.events.clear();
turnId = fixture.send();
fixture.provider.toolsManager()->executeToolCall(turnId, "t2", "fake_write", {});
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 1);
requestId = fixture.eventsOfType<Session::PermissionRequested>().at(0).requestId;
emit fixture.provider.fakeClient()->requestFailed(turnId, "boom");
resolutions = fixture.eventsOfType<Session::PermissionResolved>();
QCOMPARE(resolutions.size(), 1);
QVERIFY(resolutions.at(0).cancelled);
QVERIFY(!fixture.backend.respondPermission(requestId, "allow_once"));
QCOMPARE(fixture.eventsOfType<Session::TurnFailed>().size(), 1);
fixture.events.clear();
turnId = fixture.send();
fixture.provider.toolsManager()->executeToolCall(turnId, "t3", "fake_write", {});
QTRY_COMPARE(fixture.eventsOfType<Session::PermissionRequested>().size(), 1);
requestId = fixture.eventsOfType<Session::PermissionRequested>().at(0).requestId;
fixture.backend.cancel();
resolutions = fixture.eventsOfType<Session::PermissionResolved>();
QCOMPARE(resolutions.size(), 1);
QVERIFY(resolutions.at(0).cancelled);
QVERIFY(!fixture.backend.respondPermission(requestId, "allow_once"));
QCOMPARE(tool->executions, 0);
}
namespace {
Acp::AgentDefinition launchableAgent(const QString &id)
{
Acp::AgentDefinition agent;
agent.id = id;
agent.name = id;
agent.distribution.kind = Acp::AgentDistributionKind::Command;
agent.distribution.command = "/bin/true";
return agent;
}
class FakeConversationOps : public Chat::IConversationPort
{
public:
QString boundAgentId() const override { return boundId; }
bool conversationStarted() const override { return started; }
bool transcriptEmpty() const override { return transcriptIsEmpty; }
Acp::AgentBinding agentBinding() const override { return liveBinding; }
void bindAgent(const Acp::AgentDefinition &agent) override
{
boundId = agent.id;
calls.append("bindAgent:" + agent.id);
}
void bindLlm() override
{
boundId.clear();
calls.append("bindLlm");
}
void clearConversation() override
{
started = false;
calls.append("clear");
}
void resumeAgentSession(const QString &sessionId) override
{
calls.append("resume:" + sessionId);
}
void startFreshAgentSession() override { calls.append("fresh"); }
void startFreshAgentSession(const QString &handoverSummary) override
{
calls.append("freshWithSummary:" + handoverSummary);
}
void releaseAgentSession() override { calls.append("release"); }
QString boundId;
bool started = false;
bool transcriptIsEmpty = true;
Acp::AgentBinding liveBinding;
QStringList calls;
};
class FakeAgentCatalog : public Chat::IAgentCatalogPort
{
public:
std::optional<Acp::AgentDefinition> agentById(const QString &agentId) const override
{
const auto it = agents.constFind(agentId);
if (it == agents.constEnd())
return std::nullopt;
return *it;
}
QHash<QString, Acp::AgentDefinition> agents;
};
class FakeCompressionPort : public Chat::ICompressionPort
{
public:
QString compressionConfigurationIssue() const override { return configurationIssue; }
bool isCompressionRunning() const override { return running; }
void startTranscriptSummary() override { ++summaryStarts; }
void startCompression() override { ++compressionStarts; }
QString configurationIssue;
bool running = false;
int summaryStarts = 0;
int compressionStarts = 0;
};
class FakeSendPort : public Chat::ISendPort
{
public:
bool autoCompressEnabled() const override { return autoCompress; }
int autoCompressThreshold() const override { return threshold; }
int estimatedNextTokens() const override { return tokens; }
bool prepareChatFileForCompression(const QString &, const QStringList &) override
{
return chatFileAvailable;
}
void dispatch(const QString &message, const QStringList &) override
{
dispatched.append(message);
}
bool autoCompress = false;
int threshold = 1000;
int tokens = 0;
bool chatFileAvailable = true;
QStringList dispatched;
};
class CoordinatorFixture
{
public:
CoordinatorFixture()
: coordinator({&ops, &catalog, &compression, &send})
{
QObject::connect(
&coordinator,
&Chat::ConversationCoordinator::errorSurfaced,
&coordinator,
[this](const QString &message) { errors.append(message); });
QObject::connect(
&coordinator,
&Chat::ConversationCoordinator::switchConfirmationNeeded,
&coordinator,
[this](const QString &targetName) { confirmations.append(targetName); });
}
FakeConversationOps ops;
FakeAgentCatalog catalog;
FakeCompressionPort compression;
FakeSendPort send;
QStringList errors;
QStringList confirmations;
Chat::ConversationCoordinator coordinator;
};
} // namespace
void QodeAssistTest::testCoordinatorSwitchConfirmAndCancelRoundTrip()
{
CoordinatorFixture fixture;
fixture.coordinator.chooseAgent(launchableAgent("agent-a"));
QVERIFY(!fixture.coordinator.switchPending());
QCOMPARE(fixture.ops.calls, QStringList{"bindAgent:agent-a"});
fixture.ops.started = true;
fixture.ops.calls.clear();
fixture.coordinator.chooseLlm();
QVERIFY(fixture.coordinator.switchPending());
QCOMPARE(fixture.confirmations.size(), 1);
QVERIFY(fixture.ops.calls.isEmpty());
fixture.coordinator.cancelSwitch();
QVERIFY(!fixture.coordinator.switchPending());
QVERIFY(fixture.ops.calls.isEmpty());
QCOMPARE(fixture.ops.boundId, QString("agent-a"));
fixture.coordinator.chooseAgent(launchableAgent("agent-b"));
QVERIFY(fixture.coordinator.switchPending());
QCOMPARE(fixture.confirmations.size(), 2);
fixture.coordinator.confirmSwitch();
QVERIFY(!fixture.coordinator.switchPending());
QCOMPARE(fixture.ops.calls, (QStringList{"clear", "bindAgent:agent-b"}));
QVERIFY(!fixture.ops.started);
fixture.coordinator.confirmSwitch();
QCOMPARE(fixture.ops.calls, (QStringList{"clear", "bindAgent:agent-b"}));
}
void QodeAssistTest::testCoordinatorQuarantinesAndRestoresAgentBindings()
{
CoordinatorFixture fixture;
const Acp::AgentBinding binding{"ghost-agent", "sess-1"};
fixture.coordinator.restoreAgentBinding(binding);
QVERIFY(fixture.coordinator.readOnly());
QVERIFY(!fixture.coordinator.canStartFreshSession());
QCOMPARE(fixture.coordinator.bindingForSave(), binding);
QCOMPARE(fixture.ops.calls, (QStringList{"release", "bindLlm"}));
fixture.ops.calls.clear();
fixture.catalog.agents.insert("ghost-agent", launchableAgent("ghost-agent"));
fixture.coordinator.restoreAgentBinding(binding);
QVERIFY(!fixture.coordinator.readOnly());
QCOMPARE(fixture.ops.calls, (QStringList{"release", "bindAgent:ghost-agent", "resume:sess-1"}));
fixture.ops.liveBinding = Acp::AgentBinding{"ghost-agent", "sess-1"};
QCOMPARE(fixture.coordinator.bindingForSave(), fixture.ops.liveBinding);
fixture.ops.calls.clear();
fixture.ops.started = true;
fixture.coordinator.restoreAgentBinding(Acp::AgentBinding{"ghost-agent", QString()});
QVERIFY(fixture.coordinator.readOnly());
QVERIFY(fixture.coordinator.canStartFreshSession());
QCOMPARE(fixture.ops.calls, (QStringList{"release", "bindAgent:ghost-agent"}));
fixture.coordinator.startFreshSession();
QVERIFY(!fixture.coordinator.readOnly());
QVERIFY(fixture.ops.calls.contains("fresh"));
}
void QodeAssistTest::testCoordinatorRefusesIntentsWhileReadOnly()
{
CoordinatorFixture fixture;
fixture.coordinator.agentSessionUnavailable("gone");
QVERIFY(fixture.coordinator.readOnly());
fixture.coordinator.requestSend("hi", {});
QCOMPARE(fixture.send.dispatched.size(), 0);
QCOMPARE(fixture.errors.size(), 1);
QVERIFY(fixture.coordinator.refuseWhileReadOnly());
QCOMPARE(fixture.errors.size(), 2);
fixture.coordinator.compressionSettled();
QCOMPARE(fixture.send.dispatched.size(), 0);
fixture.coordinator.startFreshSession();
QVERIFY(!fixture.coordinator.readOnly());
QVERIFY(!fixture.coordinator.refuseWhileReadOnly());
fixture.coordinator.requestSend("hi again", {});
QCOMPARE(fixture.send.dispatched, QStringList{"hi again"});
}
void QodeAssistTest::testCoordinatorDefersTheSendForAutoCompress()
{
CoordinatorFixture fixture;
fixture.send.autoCompress = true;
fixture.send.threshold = 100;
fixture.send.tokens = 150;
fixture.coordinator.requestSend("big message", {});
QCOMPARE(fixture.send.dispatched.size(), 0);
QCOMPARE(fixture.compression.compressionStarts, 1);
QVERIFY(fixture.coordinator.hasDeferredSend());
fixture.coordinator.requestSend("while compressing", {});
QCOMPARE(fixture.send.dispatched, QStringList{"while compressing"});
QCOMPARE(fixture.compression.compressionStarts, 1);
fixture.coordinator.compressionSettled();
QCOMPARE(fixture.send.dispatched, (QStringList{"while compressing", "big message"}));
QVERIFY(!fixture.coordinator.hasDeferredSend());
fixture.send.tokens = 50;
fixture.coordinator.requestSend("small", {});
QCOMPARE(fixture.send.dispatched.size(), 3);
QCOMPARE(fixture.compression.compressionStarts, 1);
fixture.send.tokens = 150;
fixture.ops.boundId = "agent-a";
fixture.coordinator.requestSend("agent bound", {});
QCOMPARE(fixture.send.dispatched.size(), 4);
QCOMPARE(fixture.compression.compressionStarts, 1);
fixture.ops.boundId.clear();
fixture.send.chatFileAvailable = false;
fixture.coordinator.requestSend("no chat file", {});
QCOMPARE(fixture.send.dispatched.size(), 5);
QCOMPARE(fixture.compression.compressionStarts, 1);
}
void QodeAssistTest::testCoordinatorGatesTheSummaryHandover()
{
CoordinatorFixture fixture;
fixture.coordinator.agentSessionUnavailable("gone");
fixture.ops.transcriptIsEmpty = false;
QVERIFY(fixture.coordinator.canHandOverSummary());
fixture.coordinator.handOverSummary();
QCOMPARE(fixture.compression.summaryStarts, 1);
fixture.coordinator.summaryProduced("the summary");
QVERIFY(fixture.ops.calls.contains("freshWithSummary:the summary"));
QVERIFY(!fixture.coordinator.readOnly());
fixture.coordinator.agentSessionUnavailable("gone again");
fixture.compression.configurationIssue = "no chat configuration is assigned";
QVERIFY(!fixture.coordinator.canHandOverSummary());
QVERIFY(fixture.coordinator.summaryHandoverTooltip().contains("cannot be produced"));
fixture.coordinator.handOverSummary();
QCOMPARE(fixture.compression.summaryStarts, 1);
fixture.compression.configurationIssue.clear();
fixture.ops.transcriptIsEmpty = true;
QVERIFY(!fixture.coordinator.canHandOverSummary());
fixture.ops.transcriptIsEmpty = false;
fixture.compression.running = true;
QVERIFY(fixture.coordinator.canHandOverSummary());
fixture.coordinator.handOverSummary();
QCOMPARE(fixture.compression.summaryStarts, 1);
QVERIFY(!fixture.errors.isEmpty());
}
void QodeAssistTest::testCoordinatorShrinkContextRoutesByBackendKind()
{
CoordinatorFixture fixture;
fixture.ops.transcriptIsEmpty = false;
fixture.coordinator.shrinkContext();
QCOMPARE(fixture.compression.compressionStarts, 1);
QCOMPARE(fixture.compression.summaryStarts, 0);
fixture.compression.configurationIssue = "no chat configuration is assigned";
QVERIFY(!fixture.coordinator.canShrinkContext());
fixture.coordinator.shrinkContext();
QCOMPARE(fixture.compression.compressionStarts, 1);
QCOMPARE(fixture.errors.size(), 1);
QVERIFY(fixture.coordinator.shrinkContextTooltip().contains("Unavailable"));
fixture.compression.configurationIssue.clear();
fixture.ops.boundId = "agent-a";
QVERIFY(fixture.coordinator.canShrinkContext());
fixture.coordinator.shrinkContext();
QCOMPARE(fixture.compression.compressionStarts, 1);
QCOMPARE(fixture.compression.summaryStarts, 1);
fixture.ops.transcriptIsEmpty = true;
QVERIFY(!fixture.coordinator.canShrinkContext());
fixture.coordinator.shrinkContext();
QCOMPARE(fixture.compression.summaryStarts, 1);
QVERIFY(fixture.coordinator.shrinkContextTooltip().contains("nothing to summarise"));
fixture.ops.transcriptIsEmpty = false;
fixture.compression.configurationIssue = "no chat configuration is assigned";
QVERIFY(!fixture.coordinator.canShrinkContext());
fixture.coordinator.shrinkContext();
QCOMPARE(fixture.compression.summaryStarts, 1);
fixture.compression.configurationIssue.clear();
fixture.compression.running = true;
const int errorsBeforeBusy = fixture.errors.size();
fixture.coordinator.shrinkContext();
QCOMPARE(fixture.compression.summaryStarts, 1);
QCOMPARE(fixture.errors.size(), errorsBeforeBusy + 1);
const int dispatchedBeforeBusySend = fixture.send.dispatched.size();
fixture.coordinator.requestSend("while summarising", {});
QCOMPARE(fixture.send.dispatched.size(), dispatchedBeforeBusySend);
QCOMPARE(fixture.errors.size(), errorsBeforeBusy + 2);
fixture.compression.running = false;
fixture.coordinator.agentSessionUnavailable("gone");
QVERIFY(!fixture.coordinator.canShrinkContext());
const int errorsBeforeReadOnly = fixture.errors.size();
fixture.coordinator.shrinkContext();
QCOMPARE(fixture.errors.size(), errorsBeforeReadOnly + 1);
QCOMPARE(fixture.compression.summaryStarts, 1);
QCOMPARE(fixture.compression.compressionStarts, 1);
}
void QodeAssistTest::testAcpBackendTracksAgentSlashCommands()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setAvailableCommandUpdates(
{QJsonArray{
QJsonObject{
{"name", QStringLiteral("help")},
{"description", QStringLiteral("Show help")},
{"input", QJsonObject{{"hint", QStringLiteral("topic")}}}},
QJsonObject{
{"name", QStringLiteral("review")},
{"description", QStringLiteral("Review\nthe diff")}}},
QJsonArray{
QJsonObject{
{"name", QStringLiteral("help")},
{"description", QStringLiteral("Show help")}}}});
};
int changes = 0;
QObject::connect(
&fixture.backend,
&Acp::AcpChatBackend::availableCommandsChanged,
&fixture.backend,
[&changes] { ++changes; });
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QTRY_COMPARE(fixture.backend.availableCommands().size(), 1);
QCOMPARE(fixture.backend.availableCommands().at(0).name, QString("help"));
QCOMPARE(changes, 2);
fixture.backend.clearToolSession("chat.json");
QCOMPARE(fixture.backend.availableCommands().size(), 0);
QCOMPARE(changes, 3);
}
void QodeAssistTest::testEarlyAgentCommandsSurviveEstablishment()
{
AcpBackendFixture fixture;
fixture.configure = [](FakeAcpAgent *agent) {
agent->setCommandsBeforeSessionReply(
QJsonArray{QJsonObject{
{"name", QStringLiteral("help")},
{"description", QStringLiteral("Show help")}}});
};
fixture.send("hi");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.backend.availableCommands().size(), 1);
QCOMPARE(fixture.backend.availableCommands().at(0).name, QString("help"));
}
void QodeAssistTest::testAgentCommandInvocationSendsPlainPrompt()
{
AcpBackendFixture fixture;
fixture.send("/review src/main.cpp");
QTRY_COMPARE(fixture.eventsOfType<Session::TurnCompleted>().size(), 1);
QCOMPARE(fixture.agent->prompts().size(), 1);
const QJsonArray prompt = fixture.agent->prompts().at(0);
QCOMPARE(prompt.size(), 1);
QCOMPARE(prompt.at(0).toObject().value("type").toString(), QString("text"));
QCOMPARE(
prompt.at(0).toObject().value("text").toString(), QString("/review src/main.cpp"));
}
void QodeAssistTest::testFileMentionSelectionFollowsChatToolsSetting()
{
auto &enableChatTools = Settings::chatAssistantSettings().enableChatTools;
const bool originalValue = enableChatTools();
const auto restore = qScopeGuard(
[&] { enableChatTools.setValue(originalValue, Utils::BaseAspect::BeQuiet); });
Chat::FileMentionItem item;
QStringList attachRequests;
connect(
&item,
&Chat::FileMentionItem::fileAttachRequested,
this,
[&attachRequests](const QStringList &paths) { attachRequests += paths; });
enableChatTools.setValue(true, Utils::BaseAspect::BeQuiet);
QVariantMap result = item.handleFileSelection("/proj/src/main.cpp", "src/main.cpp", "proj", "main");
QCOMPARE(result.value("mode").toString(), QString("mention"));
QCOMPARE(result.value("mentionText").toString(), QString("@main.cpp "));
QVERIFY(attachRequests.isEmpty());
enableChatTools.setValue(false, Utils::BaseAspect::BeQuiet);
result = item.handleFileSelection("/proj/src/main.cpp", "src/main.cpp", "proj", "main");
QCOMPARE(result.value("mode").toString(), QString("attach"));
QCOMPARE(attachRequests, QStringList{"/proj/src/main.cpp"});
}
void QodeAssistTest::testChatModelExposesSessionRowsDirectly()
{
Chat::ChatModel model;
Session::MessageRow tool;
tool.kind = Session::RowKind::AgentTool;
tool.id = "t1";
tool.toolKind = "edit";
tool.toolStatus = "completed";
tool.toolDetails = QJsonObject{{"diffs", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}};
tool.usage = Session::Usage{10, 5, 0, 0};
model.resetMessages({tool});
QCOMPARE(model.rowCount(), 1);
QCOMPARE(
model.data(model.index(0), Chat::ChatModel::RoleType).value<Chat::ChatModel::ChatRole>(),
Chat::ChatModel::ChatRole::Tool);
QCOMPARE(model.data(model.index(0), Chat::ChatModel::ToolKind).toString(), QString("edit"));
const QVariant details = model.data(model.index(0), Chat::ChatModel::ToolDetails);
QCOMPARE(details.value<QJsonObject>(), tool.toolDetails);
QVERIFY(details.toMap().contains("diffs"));
QCOMPARE(model.data(model.index(0), Chat::ChatModel::PromptTokens).toInt(), 10);
QCOMPARE(model.data(model.index(0), Chat::ChatModel::TotalTokens).toInt(), 15);
Session::MessageRow updated = tool;
updated.toolStatus = "failed";
model.updateMessage(0, updated);
QCOMPARE(model.data(model.index(0), Chat::ChatModel::ToolStatus).toString(), QString("failed"));
}
void QodeAssistTest::testBlockCodecRejectsForgedMarkers()
{
const QJsonObject payload{{"key", "value"}};
for (const QLatin1StringView marker : Session::knownPayloadMarkers()) {
const QString encoded = Session::encodeMarkerPayload(marker, payload);
QVERIFY(encoded.startsWith(marker));
QVERIFY(Session::hasPayloadMarker(marker, encoded));
const auto decoded = Session::decodeMarkerPayload(marker, encoded);
QVERIFY(decoded.has_value());
QCOMPARE(*decoded, payload);
QVERIFY(!Session::hasPayloadMarker(marker, QStringLiteral(" ") + encoded));
QVERIFY(!Session::decodeMarkerPayload(marker, QStringLiteral(" ") + encoded));
QVERIFY(!Session::decodeMarkerPayload(marker, QStringLiteral("echoed: ") + encoded));
QVERIFY(!Session::decodeMarkerPayload(marker, QString(marker)));
QVERIFY(!Session::decodeMarkerPayload(marker, marker + QStringLiteral("not json")));
QVERIFY(!Session::decodeMarkerPayload(marker, marker + QStringLiteral("[1, 2]")));
}
}
void QodeAssistTest::testFileEditParsingRequiresTheMarkerAtTheStart()
{
const QString encoded = Session::encodeFileEditPayload(QJsonObject{{"edit_id", "e1"}});
QVERIFY(Session::isFileEditPayload(encoded));
const auto parsed = Session::parseFileEditPayload(encoded);
QVERIFY(parsed.has_value());
QCOMPARE(parsed->value("edit_id").toString(), QString("e1"));
const QString echoed = QStringLiteral("tool result quoting ") + encoded;
QVERIFY(!Session::isFileEditPayload(echoed));
QVERIFY(!Session::parseFileEditPayload(echoed));
}
void QodeAssistTest::testPermissionOptionsCarryAllowsAcrossTheSeam()
{
Session::PermissionBlock block;
block.requestId = "p1";
block.title = "Edit main.cpp";
block.options
= {Session::PermissionOption{"a", "Allow", "allow_once"},
Session::PermissionOption{"b", "Always", "allow_always"},
Session::PermissionOption{"c", "Deny", "reject_once"}};
const QString encoded = Session::encodePermissionBlock(block);
const auto payload = Session::decodeMarkerPayload(Session::permissionPayloadMarker, encoded);
QVERIFY(payload.has_value());
const QJsonArray options = payload->value("options").toArray();
QCOMPARE(options.size(), 3);
QCOMPARE(options.at(0).toObject().value("allows").toBool(), true);
QCOMPARE(options.at(1).toObject().value("allows").toBool(), true);
QCOMPARE(options.at(2).toObject().value("allows").toBool(), false);
const auto decoded = Session::decodePermissionBlock(encoded);
QVERIFY(decoded.has_value());
QCOMPARE(decoded->options, block.options);
}
void QodeAssistTest::testChatFileStoreRoundTripsStoredContent()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
const QString chatFilePath = dir.filePath("chat.json");
const QByteArray original = QByteArrayLiteral("int main() { return 0; }\n");
QString storedPath;
QVERIFY(Chat::ChatFileStore::saveContentToStorage(
chatFilePath, "main.cpp", QString::fromLatin1(original.toBase64()), storedPath));
QVERIFY(!storedPath.isEmpty());
QCOMPARE(Chat::ChatFileStore::loadRawContentFromStorage(chatFilePath, storedPath), original);
QCOMPARE(
Chat::ChatFileStore::loadContentFromStorage(chatFilePath, storedPath),
QString::fromLatin1(original.toBase64()));
const QString contentFolder = Chat::ChatFileStore::getChatContentFolder(chatFilePath);
QVERIFY(QFileInfo(QDir(contentFolder).filePath(storedPath)).exists());
}
void QodeAssistTest::testLegacyChatFileLoadsThroughTheFileStore()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
QJsonObject root;
root["version"] = "0.2";
root["messages"] = QJsonArray{
QJsonObject{{"role", 1}, {"content", "explain this"}, {"id", "u1"}},
QJsonObject{{"role", 2}, {"content", "here is the answer"}, {"id", "r1"}}};
const QString filePath = dir.filePath("legacy.json");
QFile file(filePath);
QVERIFY(file.open(QIODevice::WriteOnly));
file.write(QJsonDocument(root).toJson());
file.close();
Session::ConversationHistory history;
Acp::AgentBinding binding;
const Chat::SerializationResult result
= Chat::ChatFileStore::loadFromFile(history, binding, filePath);
QVERIFY(result.success);
QVERIFY(result.warningMessage.isEmpty());
QVERIFY(binding.isEmpty());
QCOMPARE(history.size(), 2);
QCOMPARE(history.at(0).role, Session::MessageRole::User);
QCOMPARE(
std::get<Session::TextBlock>(history.at(0).blocks.at(0)),
(Session::TextBlock{"explain this"}));
QCOMPARE(history.at(1).role, Session::MessageRole::Assistant);
}
void QodeAssistTest::testPickerObservesTheSharedAgentCatalog()
{
Acp::AgentCatalogStore store;
Chat::ChatConfigurationController controller;
QSignalSpy listChanged(
&controller, &Chat::ChatConfigurationController::availableConfigurationsChanged);
controller.setAgentCatalog(&store);
QVERIFY(listChanged.count() >= 1);
QVERIFY(!controller.availableConfigurations().isEmpty());
const int before = listChanged.count();
emit store.catalogChanged();
QCOMPARE(listChanged.count(), before + 1);
controller.setAgentCatalog(&store);
QCOMPARE(listChanged.count(), before + 1);
}
} // namespace QodeAssist