refactor: decouple tests

This commit is contained in:
Petr Mironychev
2026-07-22 10:28:19 +02:00
parent 583b5de14c
commit f6f45a3f15
54 changed files with 7180 additions and 5758 deletions

View File

@@ -183,9 +183,32 @@ extend_qtc_plugin(QodeAssist
CONDITION WITH_TESTS
DEPENDS Qt::Test
SOURCES
tests/QodeAssistTest.hpp tests/QodeAssistTest.cpp
tests/SessionTestSupport.hpp
tests/CompletionTestSupport.hpp
tests/FakeAcpAgent.hpp
tests/FakeLlmProvider.hpp
tests/CodeHandlerTest.hpp tests/CodeHandlerTest.cpp
tests/LlmSuggestionTest.hpp tests/LlmSuggestionTest.cpp
tests/ClaudeCacheControlTest.hpp tests/ClaudeCacheControlTest.cpp
tests/DocumentContextReaderTest.hpp tests/DocumentContextReaderTest.cpp
tests/ChatHistorySerializerTest.hpp tests/ChatHistorySerializerTest.cpp
tests/SessionTest.hpp tests/SessionTest.cpp
tests/RowAudienceTest.hpp tests/RowAudienceTest.cpp
tests/SessionPermissionsTest.hpp tests/SessionPermissionsTest.cpp
tests/TurnContextTest.hpp tests/TurnContextTest.cpp
tests/AgentCatalogTest.hpp tests/AgentCatalogTest.cpp
tests/AcpChatBackendTest.hpp tests/AcpChatBackendTest.cpp
tests/ToolsManagerGateTest.hpp tests/ToolsManagerGateTest.cpp
tests/AgentKnowledgeServerTest.hpp tests/AgentKnowledgeServerTest.cpp
tests/AgentBindingTest.hpp tests/AgentBindingTest.cpp
tests/LlmChatBackendTest.hpp tests/LlmChatBackendTest.cpp
tests/ConversationCoordinatorTest.hpp tests/ConversationCoordinatorTest.cpp
tests/BlockCodecTest.hpp tests/BlockCodecTest.cpp
tests/ChatFileStoreTest.hpp tests/ChatFileStoreTest.cpp
tests/ChatViewTest.hpp tests/ChatViewTest.cpp
tests/FimCompletionEngineTest.hpp tests/FimCompletionEngineTest.cpp
tests/AgenticCompletionEngineTest.hpp tests/AgenticCompletionEngineTest.cpp
tests/AcpCompletionEngineTest.hpp tests/AcpCompletionEngineTest.cpp
)
if(WITH_TESTS)

View File

@@ -1,28 +1,6 @@
/*
* Copyright (C) 2023 The Qt Company Ltd.
* Copyright (C) 2024-2026 Petr Mironychev
*
* This file is part of QodeAssist.
*
* The Qt Company portions:
* SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0+ OR GPL-3.0 WITH Qt-GPL-exception-1.0
*
* Petr Mironychev portions:
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*
* Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
*/
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "completion/CodeCompletionController.hpp"

View File

@@ -1,4 +1,3 @@
// Copyright (C) 2023 The Qt Company Ltd.
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE

View File

@@ -1,4 +1,3 @@
// Copyright (C) 2023 The Qt Company Ltd.
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE

View File

@@ -1,28 +1,6 @@
/*
* Copyright (C) 2023 The Qt Company Ltd.
* Copyright (C) 2024-2026 Petr Mironychev
*
* This file is part of QodeAssist.
*
* The Qt Company portions:
* SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0+ OR GPL-3.0 WITH Qt-GPL-exception-1.0
*
* Petr Mironychev portions:
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*
* Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
*/
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once

1119
tests/AcpChatBackendTest.cpp Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class AcpChatBackendTest final : public QObject
{
Q_OBJECT
private slots:
void testAcpBackendStreamsTextAndThinking();
void testAcpBackendStartsSessionInWorkingDirectory();
void testAcpBackendAuthenticatesWhenTheAgentAsksForIt();
void testAcpBackendReportsPromptFailure();
void testAcpBackendCancelInterruptsTheTurn();
void testAcpBackendSendsAttachmentsAsEmbeddedResources();
void testAcpBackendInlinesAttachmentsWithoutEmbeddedContextSupport();
void testAcpBackendReportsAttachmentsItCannotLoad();
void testAcpBackendSendsImagesWhenTheAgentAcceptsThem();
void testAcpBackendNamesImagesTheAgentCannotAccept();
void testAcpBackendFencesAttachmentsThatContainFences();
void testAcpBackendTruncatesOversizedAttachments();
void testAcpBackendRejectsAnEmptyTurn();
void testAcpBackendSendsAttachmentsWithoutAnyMessageText();
void testAcpBackendRaisesAgentPermissionRequests();
void testAcpBackendSendsThePermissionAnswerToTheAgent();
void testAcpBackendCancelsOutstandingPermissionRequests();
void testAcpBackendSeedsAHandoverSummaryOnce();
void testAcpBackendDropsAHandoverSummaryAfterACancelledTurn();
void testAcpBackendDropsAHandoverSummaryWithTheConversation();
void testAcpBackendOffersKnowledgeToHttpCapableAgents();
void testAcpBackendWithholdsKnowledgeFromAgentsWithoutHttpMcp();
void testAcpBackendStopsTheKnowledgeServerWithTheSession();
void testAcpBackendSurvivesAKnowledgeServerThatWillNotStart();
void testAcpBackendResumesAPersistedSession();
void testAcpBackendReportsAnUnresumableSession();
void testAcpBackendRefusesToResumeWithoutAgentSupport();
void testAcpBackendStartsFreshAfterAFailedResume();
void testAgentBindingKeepsTheResumeTargetBeforeTheFirstTurn();
void testAcpBackendCarriesTheLiveSessionIntoAResume();
void testAcpBackendEstablishesTheSessionOnlyOnce();
void testAcpBackendMapsToolCallLifecycle();
void testAcpBackendMapsPlanUpdates();
void testAcpBackendReportsUsageFromThePromptResult();
void testAcpBackendIgnoresTheCumulativeContextGauge();
void testAcpBackendForwardsTheAgentSuggestedTitle();
void testAcpBackendDeclinesAmbiguousPermissionOptions();
void testAcpBackendClampsOversizedPermissionText();
void testAcpBackendCancelsPermissionsWhenTheTurnCompletes();
void testAcpBackendCancelsPermissionRequestsWhenTheTurnFails();
void testAcpBackendTracksAgentSlashCommands();
void testEarlyAgentCommandsSurviveEstablishment();
void testAgentCommandInvocationSendsPlainPrompt();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,173 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AcpCompletionEngineTest.hpp"
#include <QSignalSpy>
#include <QTest>
#include <QTextDocument>
#include <LLMQore/AcpClient.hpp>
#include "CompletionTestSupport.hpp"
#include "FakeAcpAgent.hpp"
#include "acp/AgentDefinition.hpp"
#include "acp/AgentSpawn.hpp"
#include "completion/AcpCompletionEngine.hpp"
#include "mcp/CompletionMcpServer.hpp"
#include "settings/CodeCompletionSettings.hpp"
#include "tools/ProposeCompletionTool.hpp"
namespace QodeAssist {
namespace {
struct AcpCompletionFixture
{
AcpCompletionFixture()
: settings(new Settings::CodeCompletionSettings)
, engine(
*settings,
Settings::generalSettings(),
[this](const QString &id) -> std::optional<Acp::AgentDefinition> {
if (agentAvailable && id == agentId)
return makeAgent();
return std::nullopt;
},
reader,
&proposeTool)
{
document.setPlainText("int main() {\n return 0;\n}\n");
reader.document = &document;
settings->completionAgentId.setValue(agentId, Utils::BaseAspect::BeQuiet);
engine.setClientFactory(
[this](const Acp::AgentDefinition &, const QString &cwd, QObject *parent) {
spawnedCwd = cwd;
fakeAgent = new FakeAcpAgent(parent);
return Acp::AgentProcess{
new LLMQore::Acp::AcpClient(
fakeAgent,
{QStringLiteral("QodeAssistTest"), QStringLiteral("1.0"), QString()},
parent),
QStringLiteral("fake")};
});
}
static Acp::AgentDefinition makeAgent()
{
Acp::AgentDefinition agent;
agent.id = "fake-agent";
agent.name = "Fake Agent";
agent.distribution.kind = Acp::AgentDistributionKind::Command;
agent.distribution.command = "/bin/true";
return agent;
}
QString agentId = "fake-agent";
bool agentAvailable = true;
QString spawnedCwd;
FakeAcpAgent *fakeAgent = nullptr;
FakeCompletionDocumentReader reader;
QTextDocument document;
QSharedPointer<Settings::CodeCompletionSettings> settings;
Tools::ProposeCompletionTool proposeTool;
AcpCompletionEngine engine;
};
} // anonymous namespace
void AcpCompletionEngineTest::testAcpCompletionFailsWithoutAgent()
{
AcpCompletionFixture fixture;
fixture.settings->completionAgentId.setValue(QString(), Utils::BaseAspect::BeQuiet);
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
QVERIFY(!fixture.engine.hasConfiguredAgent());
fixture.engine.request(31, {QStringLiteral("/path/to/file.cpp"), 1, 4});
QVERIFY(fixture.fakeAgent == nullptr);
QCOMPARE(ready.count(), 0);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.first().at(0).toULongLong(), quint64(31));
QVERIFY(failed.first().at(1).toString().contains(QStringLiteral("no agent selected")));
}
void AcpCompletionEngineTest::testAcpCompletionRoutesProposalToGhostText()
{
AcpCompletionFixture fixture;
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
QVERIFY(fixture.engine.hasConfiguredAgent());
fixture.engine.request(33, {QStringLiteral("/path/to/file.cpp"), 1, 4});
const auto result = fixture.proposeTool
.executeAsync(
QJsonObject{
{"file", "/path/to/file.cpp"}, {"text", "return 42;"}})
.result();
QVERIFY(!result.isError);
QCOMPARE(failed.count(), 0);
QCOMPARE(ready.count(), 1);
QCOMPARE(ready.first().at(0).toULongLong(), quint64(33));
QCOMPARE(ready.first().at(1).toString(), QStringLiteral("return 42;"));
const auto stale = fixture.proposeTool
.executeAsync(
QJsonObject{{"file", "/path/to/file.cpp"}, {"text", "again;"}})
.result();
QVERIFY(!stale.isError);
QCOMPARE(ready.count(), 1);
}
void AcpCompletionEngineTest::testAcpCompletionOffersMcpServerToTheAgent()
{
AcpCompletionFixture fixture;
fixture.engine.request(34, {QStringLiteral("/path/to/file.cpp"), 1, 4});
QVERIFY(fixture.fakeAgent != nullptr);
QTRY_VERIFY(!fixture.fakeAgent->offeredMcpServers().isEmpty());
const QJsonArray servers = fixture.fakeAgent->offeredMcpServers();
QCOMPARE(servers.size(), 1);
const QJsonObject server = servers.first().toObject();
QCOMPARE(server.value("name").toString(), Mcp::CompletionMcpServer::serverName());
QVERIFY(server.value("url").toString().startsWith(QStringLiteral("http://127.0.0.1:")));
QTRY_VERIFY(!fixture.fakeAgent->prompts().isEmpty());
const QString promptText = fixture.fakeAgent->prompts().first().first().toObject()
.value("text").toString();
QVERIFY(promptText.contains(QStringLiteral("propose_completion")));
}
void AcpCompletionEngineTest::testAcpCompletionSupersedesInflightRequest()
{
AcpCompletionFixture fixture;
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
fixture.engine.request(35, {QStringLiteral("/path/to/file.cpp"), 1, 4});
fixture.engine.request(36, {QStringLiteral("/path/to/file.cpp"), 2, 0});
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.first().at(0).toULongLong(), quint64(35));
QVERIFY(failed.first().at(1).toString().contains(QStringLiteral("Superseded")));
const auto result = fixture.proposeTool
.executeAsync(
QJsonObject{{"file", "/path/to/file.cpp"}, {"text", "done;"}})
.result();
QVERIFY(!result.isError);
QCOMPARE(ready.count(), 1);
QCOMPARE(ready.first().at(0).toULongLong(), quint64(36));
}
} // namespace QodeAssist

View File

@@ -0,0 +1,22 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class AcpCompletionEngineTest final : public QObject
{
Q_OBJECT
private slots:
void testAcpCompletionFailsWithoutAgent();
void testAcpCompletionRoutesProposalToGhostText();
void testAcpCompletionOffersMcpServerToTheAgent();
void testAcpCompletionSupersedesInflightRequest();
};
} // namespace QodeAssist

134
tests/AgentBindingTest.cpp Normal file
View File

@@ -0,0 +1,134 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentBindingTest.hpp"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonObject>
#include <QTemporaryDir>
#include <QTest>
#include "ChatView/ChatFileStore.hpp"
#include "acp/AgentBinding.hpp"
#include "session/ConversationHistory.hpp"
#include "session/Session.hpp"
namespace QodeAssist {
void AgentBindingTest::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 AgentBindingTest::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 AgentBindingTest::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 AgentBindingTest::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());
}
} // namespace QodeAssist

View File

@@ -0,0 +1,22 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class AgentBindingTest final : public QObject
{
Q_OBJECT
private slots:
void testAgentBindingRejectsMalformedRecords();
void testMalformedAgentBindingLeavesTheChatUnbound();
void testAgentBindingRoundTripsThroughTheChatFile();
void testChatFileWithoutAnAgentLoadsUnbound();
};
} // namespace QodeAssist

350
tests/AgentCatalogTest.cpp Normal file
View File

@@ -0,0 +1,350 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentCatalogTest.hpp"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTest>
#include "ChatView/ChatConfigurationController.hpp"
#include "acp/AgentCatalog.hpp"
#include "acp/AgentCatalogStore.hpp"
#include "acp/AgentDefinition.hpp"
#include "acp/AgentLaunch.hpp"
#include "acp/AgentRegistryParser.hpp"
#include "acp/AgentSpawn.hpp"
namespace QodeAssist {
void AgentCatalogTest::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 AgentCatalogTest::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 AgentCatalogTest::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 AgentCatalogTest::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 AgentCatalogTest::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 AgentCatalogTest::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 AgentCatalogTest::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 AgentCatalogTest::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 AgentCatalogTest::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 AgentCatalogTest::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());
}
void AgentCatalogTest::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

View File

@@ -0,0 +1,29 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class AgentCatalogTest final : public QObject
{
Q_OBJECT
private slots:
void testAgentRegistryParsesEveryDistributionKind();
void testAgentRegistryReportsUnusableEntries();
void testAgentRegistryParsesSingleAgentUserFile();
void testAgentCatalogAppliesMergePriority();
void testAgentCatalogUserFileMakesBinaryAgentLaunchable();
void testAgentLaunchConfigUsesRunnerConventions();
void testAgentSearchPathsSplitting();
void testAgentExtraSearchPathsReachTheChildEnvironment();
void testAgentForwardedVariablesReachTheChildEnvironment();
void testBundledAgentSnapshotParses();
void testPickerObservesTheSharedAgentCatalog();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,65 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentKnowledgeServerTest.hpp"
#include <QHostAddress>
#include <QSignalSpy>
#include <QTest>
#include <LLMQore/ToolsManager.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/ReadFileTool.hpp"
namespace QodeAssist {
void AgentKnowledgeServerTest::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 AgentKnowledgeServerTest::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();
}
} // namespace QodeAssist

View File

@@ -0,0 +1,20 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class AgentKnowledgeServerTest final : public QObject
{
Q_OBJECT
private slots:
void testKnowledgeServerExposesOnlyReadOnlyTools();
void testKnowledgeServerBindsLoopbackOnAnEphemeralPort();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,192 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgenticCompletionEngineTest.hpp"
#include <QSignalSpy>
#include <QTest>
#include <QTextDocument>
#include <LLMQore/ToolsManager.hpp>
#include "CompletionTestSupport.hpp"
#include "FakeLlmProvider.hpp"
#include "completion/AgenticCompletionEngine.hpp"
#include "settings/CodeCompletionSettings.hpp"
#include "settings/GeneralSettings.hpp"
#include "tools/ProposeCompletionTool.hpp"
namespace QodeAssist {
namespace {
struct AgenticEngineFixture
{
AgenticEngineFixture()
: registry(&provider)
, settings(new Settings::CodeCompletionSettings)
, engine(
Settings::generalSettings(),
*settings,
registry,
&promptProvider,
reader,
performanceLogger)
{
document.setPlainText("int main() {\n return 0;\n}\n");
reader.document = &document;
settings->useUserMessageTemplateForCC.setValue(true, Utils::BaseAspect::BeQuiet);
settings->readFullFile.setValue(true, Utils::BaseAspect::BeQuiet);
}
Tools::ProposeCompletionTool *registeredTool() const
{
return qobject_cast<Tools::ProposeCompletionTool *>(
provider.fakeClient()->tools()->tool(Tools::ProposeCompletionTool::toolId()));
}
FakeLlmProvider provider;
FakeCompletionRegistry registry;
FakeChatPromptProvider promptProvider;
FakeCompletionDocumentReader reader;
FakePerformanceLogger performanceLogger;
QTextDocument document;
QSharedPointer<Settings::CodeCompletionSettings> settings;
AgenticCompletionEngine engine;
};
} // anonymous namespace
void AgenticCompletionEngineTest::testProposeToolValidatesArguments()
{
Tools::ProposeCompletionTool tool;
QSignalSpy proposed(&tool, &Tools::ProposeCompletionTool::completionProposed);
const auto missing = tool.executeAsync(QJsonObject{}).result();
QVERIFY(missing.isError);
QCOMPARE(proposed.count(), 0);
const auto valid = tool.executeAsync(
QJsonObject{
{"file", "/path/to/file.cpp"},
{"line", 1},
{"column", 4},
{"text", "return 0;"}})
.result();
QVERIFY(!valid.isError);
QCOMPARE(proposed.count(), 1);
QCOMPARE(proposed.first().at(0).toString(), QStringLiteral("/path/to/file.cpp"));
QCOMPARE(proposed.first().at(3).toString(), QStringLiteral("return 0;"));
}
void AgenticCompletionEngineTest::testAgenticEngineRendersProposalFromTool()
{
AgenticEngineFixture fixture;
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
fixture.engine.request(21, {QStringLiteral("/path/to/file.cpp"), 1, 4});
QCOMPARE(fixture.provider.fakeClient()->requestCounter, 1);
QVERIFY(fixture.provider.lastToolsEnabled);
QVERIFY(fixture.provider.lastContext.systemPrompt.has_value());
QVERIFY(fixture.provider.lastContext.systemPrompt->contains(
QStringLiteral("propose_completion")));
QVERIFY(fixture.registeredTool() != nullptr);
const auto result = fixture.registeredTool()
->executeAsync(
QJsonObject{
{"file", "/path/to/file.cpp"}, {"text", "return 0;"}})
.result();
QVERIFY(!result.isError);
QCOMPARE(failed.count(), 0);
QCOMPARE(ready.count(), 1);
QCOMPARE(ready.first().at(0).toULongLong(), quint64(21));
QCOMPARE(ready.first().at(1).toString(), QStringLiteral("return 0;"));
fixture.provider.fakeClient()->completeRequest(
fixture.provider.fakeClient()->lastRequestId, QStringLiteral("done"));
QCOMPARE(failed.count(), 0);
QCOMPARE(ready.count(), 1);
}
void AgenticCompletionEngineTest::testAgenticEngineRejectsProviderWithoutTools()
{
AgenticEngineFixture fixture;
fixture.provider.fakeCapabilities = {};
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
fixture.engine.request(22, {QStringLiteral("/path/to/file.cpp"), 1, 4});
QCOMPARE(fixture.provider.fakeClient()->requestCounter, 0);
QCOMPARE(ready.count(), 0);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.first().at(0).toULongLong(), quint64(22));
QVERIFY(failed.first().at(1).toString().contains(QStringLiteral("tool calling")));
}
void AgenticCompletionEngineTest::testAgenticEngineRejectsFimTemplate()
{
AgenticEngineFixture fixture;
fixture.promptProvider.useFimTemplate = true;
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
fixture.engine.request(23, {QStringLiteral("/path/to/file.cpp"), 1, 4});
QCOMPARE(fixture.provider.fakeClient()->requestCounter, 0);
QCOMPARE(failed.count(), 1);
QVERIFY(failed.first().at(1).toString().contains(QStringLiteral("chat-type")));
}
void AgenticCompletionEngineTest::testAgenticEngineFailsWhenModelProposesNothing()
{
AgenticEngineFixture fixture;
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
fixture.engine.request(24, {QStringLiteral("/path/to/file.cpp"), 1, 4});
fixture.provider.fakeClient()->completeRequest(
fixture.provider.fakeClient()->lastRequestId, QStringLiteral("here is some prose"));
QCOMPARE(ready.count(), 0);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.first().at(0).toULongLong(), quint64(24));
QVERIFY(failed.first().at(1).toString().contains(QStringLiteral("without proposing")));
}
void AgenticCompletionEngineTest::testAgenticEngineRoutesLoneProposalRegardlessOfFile()
{
AgenticEngineFixture fixture;
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
fixture.engine.request(25, {QStringLiteral("/path/to/file.cpp"), 1, 4});
const auto result = fixture.registeredTool()
->executeAsync(
QJsonObject{
{"file", "/path/to/other.cpp"}, {"text", "adopted();"}})
.result();
QVERIFY(!result.isError);
QCOMPARE(ready.count(), 1);
QCOMPARE(ready.first().at(0).toULongLong(), quint64(25));
QCOMPARE(ready.first().at(1).toString(), QStringLiteral("adopted();"));
const auto idle = fixture.registeredTool()
->executeAsync(
QJsonObject{{"file", "/path/to/file.cpp"}, {"text", "late();"}})
.result();
QVERIFY(!idle.isError);
QCOMPARE(ready.count(), 1);
}
} // namespace QodeAssist

View File

@@ -0,0 +1,24 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class AgenticCompletionEngineTest final : public QObject
{
Q_OBJECT
private slots:
void testProposeToolValidatesArguments();
void testAgenticEngineRendersProposalFromTool();
void testAgenticEngineRejectsProviderWithoutTools();
void testAgenticEngineRejectsFimTemplate();
void testAgenticEngineFailsWhenModelProposesNothing();
void testAgenticEngineRoutesLoneProposalRegardlessOfFile();
};
} // namespace QodeAssist

79
tests/BlockCodecTest.cpp Normal file
View File

@@ -0,0 +1,79 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "BlockCodecTest.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <QTest>
#include "session/BlockCodec.hpp"
#include "session/ContentBlock.hpp"
#include "session/FileEditPayload.hpp"
#include "session/PermissionRequest.hpp"
namespace QodeAssist {
void BlockCodecTest::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 BlockCodecTest::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 BlockCodecTest::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);
}
} // namespace QodeAssist

21
tests/BlockCodecTest.hpp Normal file
View File

@@ -0,0 +1,21 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class BlockCodecTest final : public QObject
{
Q_OBJECT
private slots:
void testBlockCodecRejectsForgedMarkers();
void testFileEditParsingRequiresTheMarkerAtTheStart();
void testPermissionOptionsCarryAllowsAcrossTheSeam();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,76 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatFileStoreTest.hpp"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonObject>
#include <QTemporaryDir>
#include <QTest>
#include "ChatView/ChatFileStore.hpp"
#include "acp/AgentBinding.hpp"
#include "session/HistorySerializer.hpp"
#include "session/Session.hpp"
namespace QodeAssist {
void ChatFileStoreTest::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 ChatFileStoreTest::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);
}
} // namespace QodeAssist

View File

@@ -0,0 +1,20 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class ChatFileStoreTest final : public QObject
{
Q_OBJECT
private slots:
void testChatFileStoreRoundTripsStoredContent();
void testLegacyChatFileLoadsThroughTheFileStore();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,263 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatHistorySerializerTest.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <QTest>
#include "ChatView/ChatModel.hpp"
#include "SessionTestSupport.hpp"
#include "session/HistoryProjection.hpp"
#include "session/HistorySerializer.hpp"
#include "session/Session.hpp"
namespace QodeAssist {
void ChatHistorySerializerTest::testHistorySnapshotUsesCurrentVersion()
{
const QJsonObject root = Session::HistorySerializer::toJson(sampleHistory());
QCOMPARE(root.value("version").toString(), Session::HistorySerializer::currentVersion());
QCOMPARE(root.value("messages").toArray().size(), 2);
}
void ChatHistorySerializerTest::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 ChatHistorySerializerTest::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 ChatHistorySerializerTest::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 ChatHistorySerializerTest::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 ChatHistorySerializerTest::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 ChatHistorySerializerTest::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 ChatHistorySerializerTest::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 ChatHistorySerializerTest::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 ChatHistorySerializerTest::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);
}
} // namespace QodeAssist

View File

@@ -0,0 +1,28 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class ChatHistorySerializerTest final : public QObject
{
Q_OBJECT
private slots:
void testHistorySnapshotUsesCurrentVersion();
void testHistorySnapshotRoundTrip();
void testUnsupportedChatVersionIsRejected();
void testChatFileWithoutMessagesArrayIsRejected();
void testLegacyChatFileConvertsToHistory();
void testLegacyToolRowWithoutMetadataKeepsItsText();
void testChatRowsRoundTripThroughHistory();
void testCompressedChatShapeReloadsAsOneAssistantRow();
void testHistoryAppliesToChatModel();
void testAdjacentThinkingBlocksSurviveReload();
};
} // namespace QodeAssist

77
tests/ChatViewTest.cpp Normal file
View File

@@ -0,0 +1,77 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatViewTest.hpp"
#include <QTest>
#include "ChatView/ChatModel.hpp"
#include "ChatView/FileMentionItem.hpp"
#include "session/HistoryProjection.hpp"
#include "session/Session.hpp"
#include "settings/ChatAssistantSettings.hpp"
namespace QodeAssist {
void ChatViewTest::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 ChatViewTest::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"));
}
} // namespace QodeAssist

20
tests/ChatViewTest.hpp Normal file
View File

@@ -0,0 +1,20 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class ChatViewTest final : public QObject
{
Q_OBJECT
private slots:
void testFileMentionSelectionFollowsChatToolsSetting();
void testChatModelExposesSessionRowsDirectly();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,180 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ClaudeCacheControlTest.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <QTest>
#include "providers/ClaudeCacheControl.hpp"
namespace QodeAssist {
QJsonObject ClaudeCacheControlTest::expectedEphemeral(bool extendedTtl)
{
QJsonObject cacheControl{{"type", "ephemeral"}};
if (extendedTtl)
cacheControl["ttl"] = "1h";
return cacheControl;
}
void ClaudeCacheControlTest::testCacheControlBreakpointWithoutExtendedTtl()
{
const QJsonObject cacheControl = Providers::ClaudeCacheControl::buildBreakpoint(false);
QCOMPARE(cacheControl.value("type").toString(), QString("ephemeral"));
QVERIFY(!cacheControl.contains("ttl"));
}
void ClaudeCacheControlTest::testCacheControlBreakpointWithExtendedTtl()
{
const QJsonObject cacheControl = Providers::ClaudeCacheControl::buildBreakpoint(true);
QCOMPARE(cacheControl.value("type").toString(), QString("ephemeral"));
QCOMPARE(cacheControl.value("ttl").toString(), QString("1h"));
}
void ClaudeCacheControlTest::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 ClaudeCacheControlTest::testCacheControlEmptySystemStringIsNotWrapped()
{
QJsonObject request;
request["system"] = "";
Providers::ClaudeCacheControl::apply(request, false);
QVERIFY(request.value("system").isString());
}
void ClaudeCacheControlTest::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 ClaudeCacheControlTest::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 ClaudeCacheControlTest::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 ClaudeCacheControlTest::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 ClaudeCacheControlTest::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 ClaudeCacheControlTest::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 ClaudeCacheControlTest::testCacheControlEmptyToolsArrayIsNoop()
{
QJsonObject request;
request["tools"] = QJsonArray{};
Providers::ClaudeCacheControl::apply(request, false);
QVERIFY(request.value("tools").isArray());
QVERIFY(request.value("tools").toArray().isEmpty());
}
} // namespace QodeAssist

View File

@@ -0,0 +1,33 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QJsonObject>
#include <QObject>
namespace QodeAssist {
class ClaudeCacheControlTest final : public QObject
{
Q_OBJECT
private slots:
void testCacheControlBreakpointWithoutExtendedTtl();
void testCacheControlBreakpointWithExtendedTtl();
void testCacheControlSystemAsStringWrappedIntoArray();
void testCacheControlEmptySystemStringIsNotWrapped();
void testCacheControlSystemAsArrayMarksLastBlock();
void testCacheControlToolsLastEntryGetsCacheControl();
void testCacheControlSingleMessageHistorySkipped();
void testCacheControlHistoryBreakpointOnSecondToLastMessage();
void testCacheControlHistoryArrayContentMarksLastBlock();
void testCacheControlNoSystemNoToolsNoMessagesIsNoop();
void testCacheControlEmptyToolsArrayIsNoop();
private:
static QJsonObject expectedEphemeral(bool extendedTtl);
};
} // namespace QodeAssist

221
tests/CodeHandlerTest.cpp Normal file
View File

@@ -0,0 +1,221 @@
// 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 "CodeHandlerTest.hpp"
#include <QTest>
#include "completion/CodeHandler.hpp"
namespace QodeAssist {
void CodeHandlerTest::testProcessTextEmpty()
{
QCOMPARE(CodeHandler::processText("", "/file.py"), QString("\n\n"));
}
void CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::testProcessTextNoCommentsWithLanguageCodeBlock()
{
const QString input = "```python\nprint('Hello, world!')\n```";
QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("print('Hello, world!')\n"));
}
void CodeHandlerTest::testProcessTextNoCommentsWithPlainCodeBlockNoNewline()
{
const QString input = "```print('Hello, world!')\n```";
QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("print('Hello, world!')\n"));
}
void CodeHandlerTest::testProcessTextNoCommentsWithPlainCodeBlockWithNewline()
{
const QString input = "```\nprint('Hello, world!')\n```";
QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("\nprint('Hello, world!')\n"));
}
void CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::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 CodeHandlerTest::testHasCodeBlocks()
{
QVERIFY(CodeHandler::hasCodeBlocks("text\n```python\ncode\n```"));
QVERIFY(!CodeHandler::hasCodeBlocks("just a plain comment\nwith two lines"));
}
} // namespace QodeAssist

37
tests/CodeHandlerTest.hpp Normal file
View File

@@ -0,0 +1,37 @@
// 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
#pragma once
#include <QObject>
namespace QodeAssist {
class CodeHandlerTest final : public QObject
{
Q_OBJECT
private slots:
void testProcessTextEmpty();
void testProcessTextCommentsAroundCodeBlock();
void testProcessTextWithPlainCodeBlockNoNewline();
void testProcessTextWithPlainCodeBlockWithNewline();
void testProcessTextNoCommentsWithLanguageCodeBlock();
void testProcessTextNoCommentsWithPlainCodeBlockNoNewline();
void testProcessTextNoCommentsWithPlainCodeBlockWithNewline();
void testProcessTextWithMultipleCodeBlocksDifferentLanguages();
void testProcessTextWithMultipleCodeBlocksSameLanguage();
void testProcessTextWithMultiplePlainCodeBlocksWithNewline();
void testProcessTextWithMultiplePlainCodeBlocksWithoutNewline();
void testProcessTextWithEmptyLines();
void testProcessTextPlainCodeBlockWithNewlineWithEmptyLines();
void testProcessTextWithoutCodeBlock();
void testDetectLanguageFromLine();
void testDetectLanguageFromExtension();
void testCommentPrefixForDifferentLanguages();
void testHasCodeBlocks();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,61 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
#include <QStringList>
#include "context/IDocumentReader.hpp"
#include "logger/IRequestPerformanceLogger.hpp"
#include "providers/IProviderRegistry.hpp"
#include "providers/Provider.hpp"
QT_BEGIN_NAMESPACE
class QTextDocument;
QT_END_NAMESPACE
namespace QodeAssist {
class FakeCompletionRegistry : public Providers::IProviderRegistry
{
public:
explicit FakeCompletionRegistry(Providers::Provider *provider)
: m_provider(provider)
{}
Providers::Provider *getProviderByName(const QString &) override { return m_provider; }
QStringList providersNames() const override
{
return m_provider ? QStringList{m_provider->name()} : QStringList{};
}
void setProvider(Providers::Provider *provider) { m_provider = provider; }
private:
Providers::Provider *m_provider = nullptr;
};
class FakeCompletionDocumentReader : public Context::IDocumentReader
{
public:
Context::DocumentInfo readDocument(const QString &path) const override
{
if (!document)
return {};
return {document, QStringLiteral("text/x-c++src"), path};
}
QTextDocument *document = nullptr;
};
class FakePerformanceLogger : public IRequestPerformanceLogger
{
public:
void startTimeMeasurement(const QString &) override {}
void endTimeMeasurement(const QString &) override {}
void logPerformance(const QString &, qint64) override {}
};
} // namespace QodeAssist

View File

@@ -0,0 +1,380 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ConversationCoordinatorTest.hpp"
#include <QSignalSpy>
#include <QTest>
#include "ChatView/ChatConfigurationController.hpp"
#include "ChatView/ConversationCoordinator.hpp"
#include "ChatView/ConversationPorts.hpp"
#include "acp/AgentBinding.hpp"
#include "acp/AgentDefinition.hpp"
namespace QodeAssist {
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 ConversationCoordinatorTest::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 ConversationCoordinatorTest::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 ConversationCoordinatorTest::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 ConversationCoordinatorTest::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 ConversationCoordinatorTest::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 ConversationCoordinatorTest::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);
}
} // namespace QodeAssist

View File

@@ -0,0 +1,24 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class ConversationCoordinatorTest final : public QObject
{
Q_OBJECT
private slots:
void testCoordinatorSwitchConfirmAndCancelRoundTrip();
void testCoordinatorQuarantinesAndRestoresAgentBindings();
void testCoordinatorRefusesIntentsWhileReadOnly();
void testCoordinatorDefersTheSendForAutoCompress();
void testCoordinatorGatesTheSummaryHandover();
void testCoordinatorShrinkContextRoutesByBackendKind();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,364 @@
// 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 "DocumentContextReaderTest.hpp"
#include <QTest>
#include <QTextDocument>
#include "context/DocumentContextReader.hpp"
#include "llmcore/ContextData.hpp"
#include "settings/CodeCompletionSettings.hpp"
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";
} // namespace
Context::DocumentContextReader DocumentContextReaderTest::createReader(const QString &text)
{
auto *document = new QTextDocument(this);
document->setPlainText(text);
return Context::DocumentContextReader(document, kTestMimeType, kTestFilePath);
}
QSharedPointer<Settings::CodeCompletionSettings> DocumentContextReaderTest::createSettingsForWholeFile()
{
auto settings = QSharedPointer<Settings::CodeCompletionSettings>::create();
settings->readFullFile.setValue(true, Utils::BaseAspect::BeQuiet);
return settings;
}
QSharedPointer<Settings::CodeCompletionSettings> DocumentContextReaderTest::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;
}
void DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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 DocumentContextReaderTest::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}));
}
} // namespace QodeAssist

View File

@@ -0,0 +1,42 @@
// 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
#pragma once
#include <QObject>
#include <QSharedPointer>
#include <QString>
#include "context/DocumentContextReader.hpp"
#include "settings/CodeCompletionSettings.hpp"
namespace QodeAssist {
class DocumentContextReaderTest final : public QObject
{
Q_OBJECT
private slots:
void testGetLineText();
void testGetContext();
void testGetContextWithCopyright();
void testReadWholeFile();
void testReadWholeFileWithCopyright();
void testReadWholeFileWithMultilineCopyright();
void testFindCopyrightSingleLine();
void testFindCopyrightMultiLine();
void testFindCopyrightMultipleBlocks();
void testFindCopyrightNoCopyright();
void testGetContextBetween();
void testPrepareContext();
private:
Context::DocumentContextReader createReader(const QString &text);
QSharedPointer<Settings::CodeCompletionSettings> createSettingsForWholeFile();
QSharedPointer<Settings::CodeCompletionSettings> createSettingsForLines(
int linesBefore, int linesAfter);
};
} // namespace QodeAssist

View File

@@ -32,7 +32,18 @@ public:
Q_UNUSED(endpoint)
Q_UNUSED(mode)
lastPayload = payload;
return QStringLiteral("fake-req-%1").arg(++requestCounter);
lastRequestId = QStringLiteral("fake-req-%1").arg(++requestCounter);
return lastRequestId;
}
void completeRequest(const ::LLMQore::RequestID &requestId, const QString &fullText)
{
emit requestCompleted(requestId, fullText);
}
void failActiveRequest(const ::LLMQore::RequestID &requestId, const QString &error)
{
emit requestFailed(requestId, error);
}
::LLMQore::RequestID ask(const QString &prompt, ::LLMQore::RequestMode mode) override
@@ -49,6 +60,7 @@ public:
}
QJsonObject lastPayload;
::LLMQore::RequestID lastRequestId;
int requestCounter = 0;
protected:
@@ -115,11 +127,24 @@ public:
bool supportsToolHistory() const override { return true; }
};
class FakeFimTemplate : public Templates::PromptTemplate
{
public:
Templates::TemplateType type() const override { return Templates::TemplateType::FIM; }
QString name() const override { return QStringLiteral("FakeFimTemplate"); }
QStringList stopWords() const override { return {}; }
void prepareRequest(QJsonObject &, const LLMCore::ContextData &) const override {}
QString description() const override { return {}; }
bool isSupportProvider(Providers::ProviderID) const override { return true; }
};
class FakeChatPromptProvider : public Templates::IPromptProvider
{
public:
Templates::PromptTemplate *getTemplateByName(const QString &) const override
{
if (useFimTemplate)
return &m_fimTemplate;
return &m_template;
}
@@ -130,8 +155,11 @@ public:
return {m_template.name()};
}
bool useFimTemplate = false;
private:
mutable FakeChatTemplate m_template;
mutable FakeFimTemplate m_fimTemplate;
};
class FakeLlmProvider : public Providers::Provider
@@ -152,13 +180,15 @@ public:
Templates::PromptTemplate *,
LLMCore::ContextData context,
LLMCore::RequestType,
bool,
bool isToolsEnabled,
bool) override
{
lastContext = context;
lastToolsEnabled = isToolsEnabled;
}
LLMCore::ContextData lastContext;
bool lastToolsEnabled = false;
QFuture<QList<QString>> getInstalledModels(const QString &) override
{
@@ -167,10 +197,9 @@ public:
Providers::ProviderID providerID() const override { return Providers::ProviderID::Any; }
Providers::ProviderCapabilities capabilities() const override
{
return Providers::ProviderCapability::Tools;
}
Providers::ProviderCapabilities capabilities() const override { return fakeCapabilities; }
Providers::ProviderCapabilities fakeCapabilities = Providers::ProviderCapability::Tools;
::LLMQore::BaseClient *client() const override { return m_client; }
QString apiKey() const override { return {}; }

View File

@@ -0,0 +1,265 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "FimCompletionEngineTest.hpp"
#include <QSignalSpy>
#include <QTest>
#include <QTextDocument>
#include "CompletionTestSupport.hpp"
#include "FakeLlmProvider.hpp"
#include "completion/FimCompletionEngine.hpp"
#include "context/CompletionContextEnricher.hpp"
#include "context/ContextManager.hpp"
#include "context/TokenUtils.hpp"
#include "llmcore/ContextData.hpp"
#include "settings/CodeCompletionSettings.hpp"
#include "settings/GeneralSettings.hpp"
namespace QodeAssist {
namespace {
class FakeCompletionEnricher : public Context::ICompletionEnricher
{
public:
QString enrichmentFor(
const Context::DocumentInfo &,
const QString &prefix,
int,
int,
int maxTokens) override
{
++calls;
lastPrefix = prefix;
lastBudget = maxTokens;
return enrichment;
}
int calls = 0;
QString lastPrefix;
int lastBudget = 0;
QString enrichment = QStringLiteral("\n<semantic-context-marker>");
};
struct FimEngineFixture
{
FimEngineFixture()
: registry(&provider)
, settings(new Settings::CodeCompletionSettings)
, contextManager(nullptr)
, engine(
Settings::generalSettings(),
*settings,
registry,
&promptProvider,
reader,
contextManager,
performanceLogger,
&enricher)
{
document.setPlainText("int main() {\n return 0;\n}\n");
reader.document = &document;
settings->useSystemPrompt.setValue(true, Utils::BaseAspect::BeQuiet);
settings->useUserMessageTemplateForCC.setValue(true, Utils::BaseAspect::BeQuiet);
settings->useOpenFilesContext.setValue(false, Utils::BaseAspect::BeQuiet);
settings->readFullFile.setValue(true, Utils::BaseAspect::BeQuiet);
settings->modelOutputHandler.setValue(0, Utils::BaseAspect::BeQuiet);
settings->completionMode.setValue(0, Utils::BaseAspect::BeQuiet);
}
FakeLlmProvider provider;
FakeCompletionRegistry registry;
FakeChatPromptProvider promptProvider;
FakeCompletionDocumentReader reader;
FakePerformanceLogger performanceLogger;
QTextDocument document;
QSharedPointer<Settings::CodeCompletionSettings> settings;
Context::ContextManager contextManager;
FakeCompletionEnricher enricher;
FimCompletionEngine engine;
};
} // namespace
void FimCompletionEngineTest::testFimEngineCompletesThroughTheProvider()
{
FimEngineFixture fixture;
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
fixture.engine.request(7, {QStringLiteral("/path/to/file.cpp"), 1, 4});
auto *client = fixture.provider.fakeClient();
QCOMPARE(client->requestCounter, 1);
QVERIFY(client->lastPayload.contains("model"));
QCOMPARE(client->lastPayload.value("stream").toBool(), true);
QVERIFY(fixture.provider.lastContext.history.has_value());
QCOMPARE(fixture.provider.lastContext.history->size(), 1);
QCOMPARE(fixture.provider.lastContext.history->first().role, QStringLiteral("user"));
QVERIFY(fixture.provider.lastContext.history->first().content.contains("int main()"));
QVERIFY(fixture.provider.lastContext.systemPrompt.has_value());
client->completeRequest(client->lastRequestId, QStringLiteral("done();"));
QCOMPARE(failed.count(), 0);
QCOMPARE(ready.count(), 1);
QCOMPARE(ready.first().at(0).toULongLong(), quint64(7));
QCOMPARE(ready.first().at(1).toString(), QStringLiteral("done();"));
}
void FimCompletionEngineTest::testFimEngineCancelIsPerRequest()
{
FimEngineFixture fixture;
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
fixture.engine.request(1, {QStringLiteral("/path/to/a.cpp"), 0, 0});
const auto firstLlmId = fixture.provider.fakeClient()->lastRequestId;
fixture.engine.request(2, {QStringLiteral("/path/to/b.cpp"), 0, 0});
const auto secondLlmId = fixture.provider.fakeClient()->lastRequestId;
QVERIFY(firstLlmId != secondLlmId);
fixture.engine.cancel(1);
auto *client = fixture.provider.fakeClient();
client->completeRequest(firstLlmId, QStringLiteral("stale"));
QCOMPARE(ready.count(), 0);
client->completeRequest(secondLlmId, QStringLiteral("fresh"));
QCOMPARE(ready.count(), 1);
QCOMPARE(ready.first().at(0).toULongLong(), quint64(2));
QCOMPARE(ready.first().at(1).toString(), QStringLiteral("fresh"));
QCOMPARE(failed.count(), 0);
}
void FimCompletionEngineTest::testFimEngineStripsCodeFences()
{
FimEngineFixture fixture;
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
fixture.engine.request(3, {QStringLiteral("/path/to/file.cpp"), 1, 4});
auto *client = fixture.provider.fakeClient();
client->completeRequest(client->lastRequestId, QStringLiteral("```cpp\nint x = 1;\n```\n"));
QCOMPARE(ready.count(), 1);
const QString text = ready.first().at(1).toString();
QVERIFY(!text.contains(QStringLiteral("```")));
QVERIFY(text.contains(QStringLiteral("int x = 1;")));
}
void FimCompletionEngineTest::testFimEngineFailsWithoutDocument()
{
FimEngineFixture fixture;
fixture.reader.document = nullptr;
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
fixture.engine.request(4, {QStringLiteral("/path/to/missing.cpp"), 0, 0});
QCOMPARE(fixture.provider.fakeClient()->requestCounter, 0);
QCOMPARE(ready.count(), 0);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.first().at(0).toULongLong(), quint64(4));
QVERIFY(failed.first().at(1).toString().contains(QStringLiteral("Document is not available")));
}
void FimCompletionEngineTest::testFimEngineEnrichmentFollowsCompletionMode()
{
FimEngineFixture fixture;
fixture.engine.request(10, {QStringLiteral("/path/to/file.cpp"), 1, 4});
QCOMPARE(fixture.enricher.calls, 0);
QVERIFY(fixture.provider.lastContext.systemPrompt.has_value());
QVERIFY(!fixture.provider.lastContext.systemPrompt->contains(
QStringLiteral("<semantic-context-marker>")));
fixture.settings->completionMode.setValue(1, Utils::BaseAspect::BeQuiet);
QCOMPARE(fixture.settings->completionMode.stringValue(), QStringLiteral("FIM + context"));
fixture.engine.request(11, {QStringLiteral("/path/to/file.cpp"), 1, 4});
QCOMPARE(fixture.enricher.calls, 1);
QVERIFY(fixture.enricher.lastBudget > 0);
QVERIFY(fixture.enricher.lastPrefix.contains(QStringLiteral("int main()")));
QVERIFY(fixture.provider.lastContext.systemPrompt->contains(
QStringLiteral("<semantic-context-marker>")));
}
void FimCompletionEngineTest::testFimEngineEnrichmentSkipsFimTemplates()
{
FimEngineFixture fixture;
fixture.settings->completionMode.setValue(1, Utils::BaseAspect::BeQuiet);
fixture.promptProvider.useFimTemplate = true;
fixture.engine.request(12, {QStringLiteral("/path/to/file.cpp"), 1, 4});
QCOMPARE(fixture.enricher.calls, 0);
QVERIFY(fixture.provider.lastContext.systemPrompt.has_value());
QVERIFY(!fixture.provider.lastContext.systemPrompt->contains(
QStringLiteral("<semantic-context-marker>")));
}
void FimCompletionEngineTest::testIdentifiersNearCursorOrdersAndFilters()
{
const QString prefix = QStringLiteral(
"class Widget;\nvoid setupWidget(Widget *widget) {\n widget->applyTheme(theme);\n");
const QStringList identifiers = Context::identifiersNearCursor(prefix, 3);
QCOMPARE(identifiers.size(), 3);
QCOMPARE(identifiers.at(0), QStringLiteral("theme"));
QCOMPARE(identifiers.at(1), QStringLiteral("applyTheme"));
QCOMPARE(identifiers.at(2), QStringLiteral("widget"));
QVERIFY(!identifiers.contains(QStringLiteral("void")));
QVERIFY(!identifiers.contains(QStringLiteral("class")));
const QStringList unlimited = Context::identifiersNearCursor(prefix, 10);
QVERIFY(unlimited.contains(QStringLiteral("Widget")));
QCOMPARE(unlimited.count(QStringLiteral("widget")), 1);
}
void FimCompletionEngineTest::testClampSectionsToTokenBudget()
{
const QString small = QStringLiteral("one two");
const QString large = QString(QStringLiteral("word ")).repeated(400);
const QString tail = QStringLiteral("three four");
const int budget = Context::TokenUtils::estimateTokens(small)
+ Context::TokenUtils::estimateTokens(tail) + 1;
const QString clamped = Context::clampSectionsToTokenBudget({small, large, tail}, budget);
QVERIFY(clamped.contains(small));
QVERIFY(clamped.contains(tail));
QVERIFY(!clamped.contains(QStringLiteral("word word")));
QCOMPARE(Context::clampSectionsToTokenBudget({large}, 1), QString());
QCOMPARE(Context::clampSectionsToTokenBudget({}, 100), QString());
}
void FimCompletionEngineTest::testFimEngineFailsWithoutProvider()
{
FimEngineFixture fixture;
fixture.registry.setProvider(nullptr);
QSignalSpy ready(&fixture.engine, &CompletionEngine::completionReady);
QSignalSpy failed(&fixture.engine, &CompletionEngine::completionFailed);
fixture.engine.request(5, {QStringLiteral("/path/to/file.cpp"), 0, 0});
QCOMPARE(fixture.provider.fakeClient()->requestCounter, 0);
QCOMPARE(ready.count(), 0);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.first().at(0).toULongLong(), quint64(5));
QVERIFY(failed.first().at(1).toString().contains(QStringLiteral("No provider found")));
}
} // namespace QodeAssist

View File

@@ -0,0 +1,27 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class FimCompletionEngineTest final : public QObject
{
Q_OBJECT
private slots:
void testFimEngineCompletesThroughTheProvider();
void testFimEngineCancelIsPerRequest();
void testFimEngineStripsCodeFences();
void testFimEngineFailsWithoutDocument();
void testFimEngineEnrichmentFollowsCompletionMode();
void testFimEngineEnrichmentSkipsFimTemplates();
void testIdentifiersNearCursorOrdersAndFilters();
void testClampSectionsToTokenBudget();
void testFimEngineFailsWithoutProvider();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,313 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "LlmChatBackendTest.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <QSignalSpy>
#include <QTest>
#include <LLMQore/ToolsManager.hpp>
#include "ChatView/LlmChatBackend.hpp"
#include "FakeLlmProvider.hpp"
#include "session/ConversationHistory.hpp"
#include "session/HistoryProjection.hpp"
#include "session/Session.hpp"
#include "session/TurnLedger.hpp"
namespace QodeAssist {
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 LlmChatBackendTest::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 LlmChatBackendTest::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 LlmChatBackendTest::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 LlmChatBackendTest::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 LlmChatBackendTest::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 LlmChatBackendTest::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 LlmChatBackendTest::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 QodeAssist

View File

@@ -0,0 +1,25 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class LlmChatBackendTest final : public QObject
{
Q_OBJECT
private slots:
void testTurnLedgerTracksTheActiveTurn();
void testTurnLedgerDrainsPermissionsOnTurnEnd();
void testLlmBackendStreamsThroughAFakeClient();
void testRenderHistoryKeepsToolExchangePairs();
void testLlmBackendIgnoresEventsFromOtherRequests();
void testLlmBackendPermissionRoundTrip();
void testLlmBackendDrainsPermissionsWhenTheTurnEnds();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,88 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "LlmSuggestionTest.hpp"
#include <QTest>
#include "completion/LLMSuggestion.hpp"
namespace QodeAssist {
void LlmSuggestionTest::testReplaceLengthEmptyRight()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", ""), 0);
}
void LlmSuggestionTest::testReplaceLengthNoOverlap()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", "bar"), 0);
}
void LlmSuggestionTest::testReplaceLengthSingleCharNoOverlap()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("a", "b"), 0);
}
void LlmSuggestionTest::testReplaceLengthLcpExtendsRight()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", "myVariable"), 5);
}
void LlmSuggestionTest::testReplaceLengthLcpPartialEngineCall()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()"), 7);
}
void LlmSuggestionTest::testReplaceLengthLcpThenClosingTailExtendsFull()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};"), 9);
}
void LlmSuggestionTest::testReplaceLengthClosingTailReplaceSemicolon()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("x;", ";"), 1);
}
void LlmSuggestionTest::testReplaceLengthClosingTailReplaceParen()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")"), 1);
}
void LlmSuggestionTest::testReplaceLengthClosingTailReplaceBrackets()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("[0]", "];"), 2);
}
void LlmSuggestionTest::testReplaceLengthClosingTailReplaceBracesAndSemi()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};"), 3);
}
void LlmSuggestionTest::testReplaceLengthClosingTailWithLeadingSpace()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};"), 3);
}
void LlmSuggestionTest::testReplaceLengthClosingTailEqualsSemi()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("= 5;", ";"), 1);
}
void LlmSuggestionTest::testReplaceLengthClosingTailNoMatchingClose()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};"), 0);
}
void LlmSuggestionTest::testReplaceLengthRealCodeRightNoLcp()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", " = 5;"), 0);
}
void LlmSuggestionTest::testReplaceLengthTrailingWhitespaceOnlyLeftAlone()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("code", " "), 0);
}
} // namespace QodeAssist

View File

@@ -0,0 +1,33 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class LlmSuggestionTest final : public QObject
{
Q_OBJECT
private slots:
void testReplaceLengthEmptyRight();
void testReplaceLengthNoOverlap();
void testReplaceLengthSingleCharNoOverlap();
void testReplaceLengthLcpExtendsRight();
void testReplaceLengthLcpPartialEngineCall();
void testReplaceLengthLcpThenClosingTailExtendsFull();
void testReplaceLengthClosingTailReplaceSemicolon();
void testReplaceLengthClosingTailReplaceParen();
void testReplaceLengthClosingTailReplaceBrackets();
void testReplaceLengthClosingTailReplaceBracesAndSemi();
void testReplaceLengthClosingTailWithLeadingSpace();
void testReplaceLengthClosingTailEqualsSemi();
void testReplaceLengthClosingTailNoMatchingClose();
void testReplaceLengthRealCodeRightNoLcp();
void testReplaceLengthTrailingWhitespaceOnlyLeftAlone();
};
} // namespace QodeAssist

File diff suppressed because it is too large Load Diff

View File

@@ -1,263 +0,0 @@
// 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
#pragma once
#include <QJsonObject>
#include <QObject>
#include <QSharedPointer>
#include "context/DocumentContextReader.hpp"
#include "settings/CodeCompletionSettings.hpp"
QT_BEGIN_NAMESPACE
class QTextDocument;
QT_END_NAMESPACE
namespace QodeAssist {
class QodeAssistTest final : public QObject
{
Q_OBJECT
private slots:
void testProcessTextEmpty();
void testProcessTextCommentsAroundCodeBlock();
void testProcessTextWithPlainCodeBlockNoNewline();
void testProcessTextWithPlainCodeBlockWithNewline();
void testProcessTextNoCommentsWithLanguageCodeBlock();
void testProcessTextNoCommentsWithPlainCodeBlockNoNewline();
void testProcessTextNoCommentsWithPlainCodeBlockWithNewline();
void testProcessTextWithMultipleCodeBlocksDifferentLanguages();
void testProcessTextWithMultipleCodeBlocksSameLanguage();
void testProcessTextWithMultiplePlainCodeBlocksWithNewline();
void testProcessTextWithMultiplePlainCodeBlocksWithoutNewline();
void testProcessTextWithEmptyLines();
void testProcessTextPlainCodeBlockWithNewlineWithEmptyLines();
void testProcessTextWithoutCodeBlock();
void testDetectLanguageFromLine();
void testDetectLanguageFromExtension();
void testCommentPrefixForDifferentLanguages();
void testHasCodeBlocks();
void testReplaceLengthEmptyRight();
void testReplaceLengthNoOverlap();
void testReplaceLengthSingleCharNoOverlap();
void testReplaceLengthLcpExtendsRight();
void testReplaceLengthLcpPartialEngineCall();
void testReplaceLengthLcpThenClosingTailExtendsFull();
void testReplaceLengthClosingTailReplaceSemicolon();
void testReplaceLengthClosingTailReplaceParen();
void testReplaceLengthClosingTailReplaceBrackets();
void testReplaceLengthClosingTailReplaceBracesAndSemi();
void testReplaceLengthClosingTailWithLeadingSpace();
void testReplaceLengthClosingTailEqualsSemi();
void testReplaceLengthClosingTailNoMatchingClose();
void testReplaceLengthRealCodeRightNoLcp();
void testReplaceLengthTrailingWhitespaceOnlyLeftAlone();
void testCacheControlBreakpointWithoutExtendedTtl();
void testCacheControlBreakpointWithExtendedTtl();
void testCacheControlSystemAsStringWrappedIntoArray();
void testCacheControlEmptySystemStringIsNotWrapped();
void testCacheControlSystemAsArrayMarksLastBlock();
void testCacheControlToolsLastEntryGetsCacheControl();
void testCacheControlSingleMessageHistorySkipped();
void testCacheControlHistoryBreakpointOnSecondToLastMessage();
void testCacheControlHistoryArrayContentMarksLastBlock();
void testCacheControlNoSystemNoToolsNoMessagesIsNoop();
void testCacheControlEmptyToolsArrayIsNoop();
void testGetLineText();
void testGetContext();
void testGetContextWithCopyright();
void testReadWholeFile();
void testReadWholeFileWithCopyright();
void testReadWholeFileWithMultilineCopyright();
void testFindCopyrightSingleLine();
void testFindCopyrightMultiLine();
void testFindCopyrightMultipleBlocks();
void testFindCopyrightNoCopyright();
void testGetContextBetween();
void testPrepareContext();
void testHistorySnapshotUsesCurrentVersion();
void testHistorySnapshotRoundTrip();
void testUnsupportedChatVersionIsRejected();
void testChatFileWithoutMessagesArrayIsRejected();
void testLegacyChatFileConvertsToHistory();
void testLegacyToolRowWithoutMetadataKeepsItsText();
void testChatRowsRoundTripThroughHistory();
void testCompressedChatShapeReloadsAsOneAssistantRow();
void testHistoryAppliesToChatModel();
void testAdjacentThinkingBlocksSurviveReload();
void testSessionAppendsUserTurnBeforeSending();
void testSessionStreamsTextThinkingAndTools();
void testSessionTrimsStreamedText();
void testSessionAbandoningTheTurnCancelsTheBackend();
void testSwitchingBackendsCancelsTheActiveTurn();
void testSessionForwardsTitleFromTheEventStream();
void testSessionProjectionMatchesHistory();
void testSessionAccumulatesStreamedThinking();
void testSessionKeepsThinkingAfterToolContinuation();
void testSessionDropsPreToolTextWhenAsked();
void testSessionToolResultAppendsFileEditRow();
void testSessionRepeatedToolResultKeepsOneFileEdit();
void testRunningToolStatusInterruptsOnTurnEnd();
void testRunningToolStatusInterruptsOnLoad();
void testAgentDiffBecomesAppliedFileEdit();
void testAgentDiffSkipsUnrevertableRegistrations();
void testAgentFileEditRevertRoundTrip();
void testSessionIgnoresEchoedFileEditMarker();
void testSessionTruncationKeepsBlockOrder();
void testHistorySnapshotReportsUnreadableBlocks();
void testSessionUsageLandsOnTheTurn();
void testSessionIgnoresEventsFromOtherTurns();
void testSessionReportsFailureWithoutStartedTurn();
void testSessionIgnoresFailureFromAbandonedTurn();
void testSessionTruncateRowsRewritesHistory();
void testSessionUpsertsAgentToolCalls();
void testLlmToolLifecycleFlowsThroughOneEvent();
void testSessionKeepsOnePlanPerTurn();
void testAgentActivityKeepsStreamedTextIntact();
void testAgentToolUpdateTouchesOnlyItsOwnRow();
void testUsageSkipsRowsThatCannotShowIt();
void testAgentActivityBlocksSurviveReload();
void testEveryRowKindProjectsInOrder();
void testPromptAudienceKeepsToolsAndThinking();
void testCompressionAudienceDropsToolsAndThinking();
void testTokenCountAudienceCountsToolsAndThinking();
void testSessionRendersPermissionRequestWithItsOptions();
void testSessionForwardsPermissionAnswerToTheBackend();
void testSessionAllowAlwaysSuppressesRepeatPromptsOfTheSameKind();
void testSessionAllowAlwaysDoesNotCoverOtherToolKinds();
void testSessionNeverAutoAnswersUnclassifiedTools();
void testSessionIgnoresAnswersToUnknownPermissionRequests();
void testSessionMarksUnansweredPermissionsCancelled();
void testSessionRejectAlwaysSuppressesRepeatPromptsOfTheSameKind();
void testSessionAutoAnswerFallsBackToTheAlwaysOption();
void testSessionDoesNotAutoAnswerWithoutAMatchingOption();
void testSessionAllowAlwaysDoesNotSurviveAnotherConversation();
void testSessionAnswersTheLiveRequestWhenAnOldIdIsReused();
void testSessionCancelsPermissionsTheBackendNeverResolved();
void testSessionCancelsPermissionsTheBackendRefused();
void testPermissionBlockReopensAsUnanswerable();
void testTurnContextCollectsPartsFromPorts();
void testTurnContextSkipsWhatTheBackendDoesNotNeed();
void testFencedFileBlockOutgrowsBackticksInContent();
void testTurnContextSkillCommandScanning();
void testTurnContextWithoutSkillsPort();
void testSystemPromptRenderingWithProject();
void testSystemPromptRenderingWithoutProject();
void testAgentRegistryParsesEveryDistributionKind();
void testAgentRegistryReportsUnusableEntries();
void testAgentRegistryParsesSingleAgentUserFile();
void testAgentCatalogAppliesMergePriority();
void testAgentCatalogUserFileMakesBinaryAgentLaunchable();
void testAgentLaunchConfigUsesRunnerConventions();
void testAgentSearchPathsSplitting();
void testAgentExtraSearchPathsReachTheChildEnvironment();
void testAgentForwardedVariablesReachTheChildEnvironment();
void testBundledAgentSnapshotParses();
void testAcpBackendStreamsTextAndThinking();
void testAcpBackendStartsSessionInWorkingDirectory();
void testAcpBackendAuthenticatesWhenTheAgentAsksForIt();
void testAcpBackendReportsPromptFailure();
void testAcpBackendCancelInterruptsTheTurn();
void testAcpBackendSendsAttachmentsAsEmbeddedResources();
void testAcpBackendInlinesAttachmentsWithoutEmbeddedContextSupport();
void testAcpBackendReportsAttachmentsItCannotLoad();
void testAcpBackendSendsImagesWhenTheAgentAcceptsThem();
void testAcpBackendNamesImagesTheAgentCannotAccept();
void testAcpBackendFencesAttachmentsThatContainFences();
void testAcpBackendTruncatesOversizedAttachments();
void testAcpBackendRejectsAnEmptyTurn();
void testAcpBackendSendsAttachmentsWithoutAnyMessageText();
void testAcpBackendRaisesAgentPermissionRequests();
void testAcpBackendSendsThePermissionAnswerToTheAgent();
void testAcpBackendCancelsOutstandingPermissionRequests();
void testTurnLedgerTracksTheActiveTurn();
void testTurnLedgerDrainsPermissionsOnTurnEnd();
void testLlmBackendStreamsThroughAFakeClient();
void testRenderHistoryKeepsToolExchangePairs();
void testLlmBackendIgnoresEventsFromOtherRequests();
void testLlmBackendPermissionRoundTrip();
void testLlmBackendDrainsPermissionsWhenTheTurnEnds();
void testCoordinatorSwitchConfirmAndCancelRoundTrip();
void testCoordinatorQuarantinesAndRestoresAgentBindings();
void testCoordinatorRefusesIntentsWhileReadOnly();
void testCoordinatorDefersTheSendForAutoCompress();
void testCoordinatorGatesTheSummaryHandover();
void testCoordinatorShrinkContextRoutesByBackendKind();
void testAcpBackendTracksAgentSlashCommands();
void testEarlyAgentCommandsSurviveEstablishment();
void testAgentCommandInvocationSendsPlainPrompt();
void testFileMentionSelectionFollowsChatToolsSetting();
void testChatModelExposesSessionRowsDirectly();
void testBlockCodecRejectsForgedMarkers();
void testFileEditParsingRequiresTheMarkerAtTheStart();
void testPermissionOptionsCarryAllowsAcrossTheSeam();
void testChatFileStoreRoundTripsStoredContent();
void testLegacyChatFileLoadsThroughTheFileStore();
void testPickerObservesTheSharedAgentCatalog();
void testAcpBackendSeedsAHandoverSummaryOnce();
void testAcpBackendDropsAHandoverSummaryAfterACancelledTurn();
void testAcpBackendDropsAHandoverSummaryWithTheConversation();
void testToolSafetyDefaultsToMutating();
void testToolsManagerWithoutAGateRunsImmediately();
void testToolsManagerGateCanDeclineAToolCall();
void testToolsManagerGateCanAllowAToolCall();
void testToolsManagerGateResumesTheQueueAfterADenial();
void testKnowledgeServerExposesOnlyReadOnlyTools();
void testKnowledgeServerBindsLoopbackOnAnEphemeralPort();
void testAcpBackendOffersKnowledgeToHttpCapableAgents();
void testAcpBackendWithholdsKnowledgeFromAgentsWithoutHttpMcp();
void testAcpBackendStopsTheKnowledgeServerWithTheSession();
void testAcpBackendSurvivesAKnowledgeServerThatWillNotStart();
void testAcpBackendResumesAPersistedSession();
void testAcpBackendReportsAnUnresumableSession();
void testAcpBackendRefusesToResumeWithoutAgentSupport();
void testAcpBackendStartsFreshAfterAFailedResume();
void testAgentBindingKeepsTheResumeTargetBeforeTheFirstTurn();
void testAcpBackendCarriesTheLiveSessionIntoAResume();
void testAcpBackendEstablishesTheSessionOnlyOnce();
void testAgentBindingRejectsMalformedRecords();
void testMalformedAgentBindingLeavesTheChatUnbound();
void testAgentBindingRoundTripsThroughTheChatFile();
void testChatFileWithoutAnAgentLoadsUnbound();
void testAcpBackendMapsToolCallLifecycle();
void testAcpBackendMapsPlanUpdates();
void testAcpBackendReportsUsageFromThePromptResult();
void testAcpBackendIgnoresTheCumulativeContextGauge();
void testAcpBackendForwardsTheAgentSuggestedTitle();
void testAcpBackendDeclinesAmbiguousPermissionOptions();
void testAcpBackendClampsOversizedPermissionText();
void testAcpBackendCancelsPermissionsWhenTheTurnCompletes();
void testAcpBackendCancelsPermissionRequestsWhenTheTurnFails();
private:
Context::DocumentContextReader createReader(const QString &text);
QSharedPointer<Settings::CodeCompletionSettings> createSettingsForWholeFile();
QSharedPointer<Settings::CodeCompletionSettings> createSettingsForLines(
int linesBefore, int linesAfter);
static QJsonObject expectedEphemeral(bool extendedTtl);
};
} // namespace QodeAssist

132
tests/RowAudienceTest.cpp Normal file
View File

@@ -0,0 +1,132 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "RowAudienceTest.hpp"
#include <QTest>
#include "session/AgentPlan.hpp"
#include "session/ContentBlock.hpp"
#include "session/HistoryProjection.hpp"
#include "session/PermissionRequest.hpp"
#include "session/Session.hpp"
namespace QodeAssist {
namespace {
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
void RowAudienceTest::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 RowAudienceTest::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 RowAudienceTest::testCompressionAudienceDropsToolsAndThinking()
{
QCOMPARE(
survivingKinds(Session::RowAudience::Compression),
(QList<Session::RowKind>{
Session::RowKind::System, Session::RowKind::User, Session::RowKind::Assistant}));
}
void RowAudienceTest::testTokenCountAudienceCountsToolsAndThinking()
{
QCOMPARE(
survivingKinds(Session::RowAudience::TokenCount),
(QList<Session::RowKind>{
Session::RowKind::System,
Session::RowKind::User,
Session::RowKind::Assistant,
Session::RowKind::Thinking,
Session::RowKind::Tool}));
}
} // namespace QodeAssist

22
tests/RowAudienceTest.hpp Normal file
View File

@@ -0,0 +1,22 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class RowAudienceTest final : public QObject
{
Q_OBJECT
private slots:
void testEveryRowKindProjectsInOrder();
void testPromptAudienceKeepsToolsAndThinking();
void testCompressionAudienceDropsToolsAndThinking();
void testTokenCountAudienceCountsToolsAndThinking();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,391 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "SessionPermissionsTest.hpp"
#include <QSignalSpy>
#include <QTest>
#include "ChatView/ChatModel.hpp"
#include "SessionTestSupport.hpp"
#include "session/HistoryProjection.hpp"
#include "session/HistorySerializer.hpp"
#include "session/PermissionRequest.hpp"
#include "session/Session.hpp"
namespace QodeAssist {
void SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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 SessionPermissionsTest::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);
}
} // namespace QodeAssist

View File

@@ -0,0 +1,33 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class SessionPermissionsTest final : public QObject
{
Q_OBJECT
private slots:
void testSessionRendersPermissionRequestWithItsOptions();
void testSessionForwardsPermissionAnswerToTheBackend();
void testSessionAllowAlwaysSuppressesRepeatPromptsOfTheSameKind();
void testSessionAllowAlwaysDoesNotCoverOtherToolKinds();
void testSessionNeverAutoAnswersUnclassifiedTools();
void testSessionIgnoresAnswersToUnknownPermissionRequests();
void testSessionMarksUnansweredPermissionsCancelled();
void testSessionRejectAlwaysSuppressesRepeatPromptsOfTheSameKind();
void testSessionAutoAnswerFallsBackToTheAlwaysOption();
void testSessionDoesNotAutoAnswerWithoutAMatchingOption();
void testSessionAllowAlwaysDoesNotSurviveAnotherConversation();
void testSessionAnswersTheLiveRequestWhenAnOldIdIsReused();
void testSessionCancelsPermissionsTheBackendNeverResolved();
void testSessionCancelsPermissionsTheBackendRefused();
void testPermissionBlockReopensAsUnanswerable();
};
} // namespace QodeAssist

971
tests/SessionTest.cpp Normal file
View File

@@ -0,0 +1,971 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "SessionTest.hpp"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonObject>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTest>
#include "ChatView/ChatModel.hpp"
#include "SessionTestSupport.hpp"
#include "context/FileEditManager.hpp"
#include "session/FileEditPayload.hpp"
#include "session/HistoryProjection.hpp"
#include "session/HistorySerializer.hpp"
#include "session/Session.hpp"
namespace QodeAssist {
void SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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 SessionTest::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);
}
} // namespace QodeAssist

50
tests/SessionTest.hpp Normal file
View File

@@ -0,0 +1,50 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class SessionTest final : public QObject
{
Q_OBJECT
private slots:
void testSessionAppendsUserTurnBeforeSending();
void testSessionStreamsTextThinkingAndTools();
void testSessionTrimsStreamedText();
void testSessionAbandoningTheTurnCancelsTheBackend();
void testSwitchingBackendsCancelsTheActiveTurn();
void testSessionForwardsTitleFromTheEventStream();
void testSessionProjectionMatchesHistory();
void testSessionAccumulatesStreamedThinking();
void testSessionKeepsThinkingAfterToolContinuation();
void testSessionDropsPreToolTextWhenAsked();
void testSessionToolResultAppendsFileEditRow();
void testSessionRepeatedToolResultKeepsOneFileEdit();
void testRunningToolStatusInterruptsOnTurnEnd();
void testRunningToolStatusInterruptsOnLoad();
void testAgentDiffBecomesAppliedFileEdit();
void testAgentDiffSkipsUnrevertableRegistrations();
void testAgentFileEditRevertRoundTrip();
void testSessionIgnoresEchoedFileEditMarker();
void testSessionTruncationKeepsBlockOrder();
void testHistorySnapshotReportsUnreadableBlocks();
void testSessionUsageLandsOnTheTurn();
void testSessionIgnoresEventsFromOtherTurns();
void testSessionReportsFailureWithoutStartedTurn();
void testSessionIgnoresFailureFromAbandonedTurn();
void testSessionTruncateRowsRewritesHistory();
void testSessionUpsertsAgentToolCalls();
void testLlmToolLifecycleFlowsThroughOneEvent();
void testSessionKeepsOnePlanPerTurn();
void testAgentActivityKeepsStreamedTextIntact();
void testAgentToolUpdateTouchesOnlyItsOwnRow();
void testUsageSkipsRowsThatCannotShowIt();
void testAgentActivityBlocksSurviveReload();
};
} // namespace QodeAssist

View File

@@ -0,0 +1,208 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QJsonObject>
#include <QList>
#include <QPair>
#include <QString>
#include <optional>
#include "ChatView/ChatModel.hpp"
#include "session/BlockCodec.hpp"
#include "session/ChatBackend.hpp"
#include "session/ContentBlock.hpp"
#include "session/ConversationHistory.hpp"
#include "session/HistoryProjection.hpp"
#include "session/PermissionRequest.hpp"
#include "session/Session.hpp"
#include "session/SessionEvent.hpp"
#include "session/TurnRequest.hpp"
namespace QodeAssist {
inline 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;
}
inline 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;
}
inline 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;
}
inline 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;
};
inline 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"}};
}
inline 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()};
}
inline Session::PermissionBlock permissionRowAt(const Session::Session &session, int index)
{
return Session::decodePermissionBlock(session.rows().at(index).content)
.value_or(Session::PermissionBlock{});
}
inline 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};
}
inline 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};
}
inline 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}};
}
inline 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());
}
} // namespace QodeAssist

View File

@@ -0,0 +1,176 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ToolsManagerGateTest.hpp"
#include <QJsonObject>
#include <QSignalSpy>
#include <QTest>
#include <LLMQore/BaseTool.hpp>
#include <LLMQore/ToolsManager.hpp>
#include "tools/BuildProjectTool.hpp"
#include "tools/CreateNewFileTool.hpp"
#include "tools/EditFileTool.hpp"
#include "tools/EditorStateTools.hpp"
#include "tools/ExecuteTerminalCommandTool.hpp"
#include "tools/GetIssuesListTool.hpp"
#include "tools/ReadFileTool.hpp"
namespace QodeAssist {
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 ToolsManagerGateTest::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 ToolsManagerGateTest::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 ToolsManagerGateTest::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 ToolsManagerGateTest::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 ToolsManagerGateTest::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);
}
} // namespace QodeAssist

View File

@@ -0,0 +1,23 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class ToolsManagerGateTest final : public QObject
{
Q_OBJECT
private slots:
void testToolSafetyDefaultsToMutating();
void testToolsManagerWithoutAGateRunsImmediately();
void testToolsManagerGateCanDeclineAToolCall();
void testToolsManagerGateCanAllowAToolCall();
void testToolsManagerGateResumesTheQueueAfterADenial();
};
} // namespace QodeAssist

196
tests/TurnContextTest.cpp Normal file
View File

@@ -0,0 +1,196 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "TurnContextTest.hpp"
#include <QTest>
#include "session/FencedText.hpp"
#include "session/Session.hpp"
#include "session/TurnContext.hpp"
#include "session/TurnContextBuilder.hpp"
#include "session/TurnContextPorts.hpp"
namespace QodeAssist {
namespace {
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;
};
Session::ProjectInfo sampleProject()
{
Session::ProjectInfo info;
info.available = true;
info.displayName = "QodeAssist";
info.sourceRoot = "/src/QodeAssist";
info.buildDirectory = "/src/QodeAssist/build";
return info;
}
} // namespace
void TurnContextTest::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 TurnContextTest::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 TurnContextTest::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 TurnContextTest::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 TurnContextTest::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 TurnContextTest::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 TurnContextTest::testSystemPromptRenderingWithoutProject()
{
Session::TurnContext context;
context.basePrompt = "You are helpful.";
QCOMPARE(
Session::renderSystemPrompt(context),
QString("You are helpful.\n# No active project in IDE"));
}
} // namespace QodeAssist

25
tests/TurnContextTest.hpp Normal file
View File

@@ -0,0 +1,25 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
namespace QodeAssist {
class TurnContextTest final : public QObject
{
Q_OBJECT
private slots:
void testTurnContextCollectsPartsFromPorts();
void testTurnContextSkipsWhatTheBackendDoesNotNeed();
void testFencedFileBlockOutgrowsBackticksInContent();
void testTurnContextSkillCommandScanning();
void testTurnContextWithoutSkillsPort();
void testSystemPromptRenderingWithProject();
void testSystemPromptRenderingWithoutProject();
};
} // namespace QodeAssist