From f6f45a3f15949d008b182a1effc3e2c6e2399f13 Mon Sep 17 00:00:00 2001
From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com>
Date: Wed, 22 Jul 2026 10:28:19 +0200
Subject: [PATCH] refactor: decouple tests
---
CMakeLists.txt | 25 +-
.../completion/CodeCompletionController.cpp | 28 +-
.../completion/CodeCompletionController.hpp | 1 -
sources/completion/LLMSuggestion.cpp | 1 -
sources/completion/LLMSuggestion.hpp | 28 +-
tests/AcpChatBackendTest.cpp | 1119 ++++
tests/AcpChatBackendTest.hpp | 61 +
tests/AcpCompletionEngineTest.cpp | 173 +
tests/AcpCompletionEngineTest.hpp | 22 +
tests/AgentBindingTest.cpp | 134 +
tests/AgentBindingTest.hpp | 22 +
tests/AgentCatalogTest.cpp | 350 ++
tests/AgentCatalogTest.hpp | 29 +
tests/AgentKnowledgeServerTest.cpp | 65 +
tests/AgentKnowledgeServerTest.hpp | 20 +
tests/AgenticCompletionEngineTest.cpp | 192 +
tests/AgenticCompletionEngineTest.hpp | 24 +
tests/BlockCodecTest.cpp | 79 +
tests/BlockCodecTest.hpp | 21 +
tests/ChatFileStoreTest.cpp | 76 +
tests/ChatFileStoreTest.hpp | 20 +
tests/ChatHistorySerializerTest.cpp | 263 +
tests/ChatHistorySerializerTest.hpp | 28 +
tests/ChatViewTest.cpp | 77 +
tests/ChatViewTest.hpp | 20 +
tests/ClaudeCacheControlTest.cpp | 180 +
tests/ClaudeCacheControlTest.hpp | 33 +
tests/CodeHandlerTest.cpp | 221 +
tests/CodeHandlerTest.hpp | 37 +
tests/CompletionTestSupport.hpp | 61 +
tests/ConversationCoordinatorTest.cpp | 380 ++
tests/ConversationCoordinatorTest.hpp | 24 +
tests/DocumentContextReaderTest.cpp | 364 ++
tests/DocumentContextReaderTest.hpp | 42 +
tests/FakeLlmProvider.hpp | 41 +-
tests/FimCompletionEngineTest.cpp | 265 +
tests/FimCompletionEngineTest.hpp | 27 +
tests/LlmChatBackendTest.cpp | 313 +
tests/LlmChatBackendTest.hpp | 25 +
tests/LlmSuggestionTest.cpp | 88 +
tests/LlmSuggestionTest.hpp | 33 +
tests/QodeAssistTest.cpp | 5436 -----------------
tests/QodeAssistTest.hpp | 263 -
tests/RowAudienceTest.cpp | 132 +
tests/RowAudienceTest.hpp | 22 +
tests/SessionPermissionsTest.cpp | 391 ++
tests/SessionPermissionsTest.hpp | 33 +
tests/SessionTest.cpp | 971 +++
tests/SessionTest.hpp | 50 +
tests/SessionTestSupport.hpp | 208 +
tests/ToolsManagerGateTest.cpp | 176 +
tests/ToolsManagerGateTest.hpp | 23 +
tests/TurnContextTest.cpp | 196 +
tests/TurnContextTest.hpp | 25 +
54 files changed, 7180 insertions(+), 5758 deletions(-)
create mode 100644 tests/AcpChatBackendTest.cpp
create mode 100644 tests/AcpChatBackendTest.hpp
create mode 100644 tests/AcpCompletionEngineTest.cpp
create mode 100644 tests/AcpCompletionEngineTest.hpp
create mode 100644 tests/AgentBindingTest.cpp
create mode 100644 tests/AgentBindingTest.hpp
create mode 100644 tests/AgentCatalogTest.cpp
create mode 100644 tests/AgentCatalogTest.hpp
create mode 100644 tests/AgentKnowledgeServerTest.cpp
create mode 100644 tests/AgentKnowledgeServerTest.hpp
create mode 100644 tests/AgenticCompletionEngineTest.cpp
create mode 100644 tests/AgenticCompletionEngineTest.hpp
create mode 100644 tests/BlockCodecTest.cpp
create mode 100644 tests/BlockCodecTest.hpp
create mode 100644 tests/ChatFileStoreTest.cpp
create mode 100644 tests/ChatFileStoreTest.hpp
create mode 100644 tests/ChatHistorySerializerTest.cpp
create mode 100644 tests/ChatHistorySerializerTest.hpp
create mode 100644 tests/ChatViewTest.cpp
create mode 100644 tests/ChatViewTest.hpp
create mode 100644 tests/ClaudeCacheControlTest.cpp
create mode 100644 tests/ClaudeCacheControlTest.hpp
create mode 100644 tests/CodeHandlerTest.cpp
create mode 100644 tests/CodeHandlerTest.hpp
create mode 100644 tests/CompletionTestSupport.hpp
create mode 100644 tests/ConversationCoordinatorTest.cpp
create mode 100644 tests/ConversationCoordinatorTest.hpp
create mode 100644 tests/DocumentContextReaderTest.cpp
create mode 100644 tests/DocumentContextReaderTest.hpp
create mode 100644 tests/FimCompletionEngineTest.cpp
create mode 100644 tests/FimCompletionEngineTest.hpp
create mode 100644 tests/LlmChatBackendTest.cpp
create mode 100644 tests/LlmChatBackendTest.hpp
create mode 100644 tests/LlmSuggestionTest.cpp
create mode 100644 tests/LlmSuggestionTest.hpp
delete mode 100644 tests/QodeAssistTest.cpp
delete mode 100644 tests/QodeAssistTest.hpp
create mode 100644 tests/RowAudienceTest.cpp
create mode 100644 tests/RowAudienceTest.hpp
create mode 100644 tests/SessionPermissionsTest.cpp
create mode 100644 tests/SessionPermissionsTest.hpp
create mode 100644 tests/SessionTest.cpp
create mode 100644 tests/SessionTest.hpp
create mode 100644 tests/SessionTestSupport.hpp
create mode 100644 tests/ToolsManagerGateTest.cpp
create mode 100644 tests/ToolsManagerGateTest.hpp
create mode 100644 tests/TurnContextTest.cpp
create mode 100644 tests/TurnContextTest.hpp
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f7847e2..19cbefc 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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)
diff --git a/sources/completion/CodeCompletionController.cpp b/sources/completion/CodeCompletionController.cpp
index 51c41ae..12ec9bb 100644
--- a/sources/completion/CodeCompletionController.cpp
+++ b/sources/completion/CodeCompletionController.cpp
@@ -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 .
- *
- * 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"
diff --git a/sources/completion/CodeCompletionController.hpp b/sources/completion/CodeCompletionController.hpp
index e617676..b420b5a 100644
--- a/sources/completion/CodeCompletionController.hpp
+++ b/sources/completion/CodeCompletionController.hpp
@@ -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
diff --git a/sources/completion/LLMSuggestion.cpp b/sources/completion/LLMSuggestion.cpp
index 4b0a328..23f21d4 100644
--- a/sources/completion/LLMSuggestion.cpp
+++ b/sources/completion/LLMSuggestion.cpp
@@ -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
diff --git a/sources/completion/LLMSuggestion.hpp b/sources/completion/LLMSuggestion.hpp
index 5bde357..dbddf85 100644
--- a/sources/completion/LLMSuggestion.hpp
+++ b/sources/completion/LLMSuggestion.hpp
@@ -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 .
- *
- * 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
diff --git a/tests/AcpChatBackendTest.cpp b/tests/AcpChatBackendTest.cpp
new file mode 100644
index 0000000..74d74fe
--- /dev/null
+++ b/tests/AcpChatBackendTest.cpp
@@ -0,0 +1,1119 @@
+// Copyright (C) 2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include "AcpChatBackendTest.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "ChatView/ChatFileStore.hpp"
+#include "FakeAcpAgent.hpp"
+#include "acp/AcpChatBackend.hpp"
+#include "acp/AgentBinding.hpp"
+#include "acp/AgentDefinition.hpp"
+#include "acp/AgentKnowledgeService.hpp"
+#include "acp/AgentSpawn.hpp"
+#include "mcp/AgentKnowledgeServer.hpp"
+#include "session/Session.hpp"
+#include "session/SessionEvent.hpp"
+
+namespace QodeAssist {
+
+namespace {
+
+Acp::AgentDefinition fakeAgentDefinition()
+{
+ Acp::AgentDefinition agent;
+ agent.id = "fake-agent";
+ agent.name = "Fake Agent";
+ agent.distribution.kind = Acp::AgentDistributionKind::Command;
+ agent.distribution.command = "/bin/true";
+ return agent;
+}
+
+class AcpBackendFixture
+{
+public:
+ AcpBackendFixture()
+ {
+ backend.setClientFactory(
+ [this](const Acp::AgentDefinition &, const QString &cwd, QObject *parent) {
+ requestedCwd = cwd;
+ agent = new FakeAcpAgent(parent);
+ configure(agent);
+ return Acp::AgentProcess{
+ new LLMQore::Acp::AcpClient(
+ agent,
+ {QStringLiteral("QodeAssistTest"), QStringLiteral("1.0"), QString()},
+ parent),
+ QStringLiteral("fake")};
+ });
+
+ QObject::connect(
+ &backend,
+ &Session::ChatBackend::sessionEvent,
+ &backend,
+ [this](const Session::SessionEvent &event) { events.append(event); });
+
+ backend.setStoredContentLoader([this](const QString &, const QString &storedPath) {
+ return storage.value(storedPath);
+ });
+
+ backend.bindAgent(fakeAgentDefinition());
+ }
+
+ void send(const QString &text)
+ {
+ Session::TurnRequest request;
+ request.userBlocks = {Session::TextBlock{text}};
+ backend.sendTurn(request);
+ }
+
+ void sendBlocks(
+ const QList &blocks,
+ const std::optional &context = std::nullopt)
+ {
+ Session::TurnRequest request;
+ request.userBlocks = blocks;
+ request.context = context;
+ backend.sendTurn(request);
+ }
+
+ QList promptBlocksOfType(const QString &type) const
+ {
+ QList matching;
+ if (!agent)
+ return matching;
+
+ const QList sent = agent->prompts();
+ if (sent.isEmpty())
+ return matching;
+
+ for (const QJsonValue &block : sent.constLast()) {
+ if (block.toObject().value("type").toString() == type)
+ matching.append(block.toObject());
+ }
+ return matching;
+ }
+
+ template
+ QList eventsOfType() const
+ {
+ QList result;
+ for (const Session::SessionEvent &event : events) {
+ if (const auto *typed = std::get_if(&event))
+ result.append(*typed);
+ }
+ return result;
+ }
+
+ std::function configure = [](FakeAcpAgent *) {};
+ QHash storage;
+ FakeAcpAgent *agent = nullptr;
+ QString requestedCwd;
+ QList events;
+ Acp::AcpChatBackend backend;
+};
+
+} // namespace
+
+namespace {
+
+QJsonObject writeToolCall()
+{
+ return QJsonObject{
+ {"toolCallId", QStringLiteral("call-1")},
+ {"title", QStringLiteral("Write main.cpp")},
+ {"kind", QStringLiteral("edit")},
+ {"status", QStringLiteral("pending")}};
+}
+
+QJsonArray writePermissionOptions()
+{
+ return QJsonArray{
+ QJsonObject{
+ {"optionId", QStringLiteral("allow")},
+ {"name", QStringLiteral("Allow")},
+ {"kind", QStringLiteral("allow_once")}},
+ QJsonObject{
+ {"optionId", QStringLiteral("deny")},
+ {"name", QStringLiteral("Deny")},
+ {"kind", QStringLiteral("reject_once")}}};
+}
+
+} // namespace
+
+namespace {
+
+class FakeKnowledgeService : public Acp::AgentKnowledgeService
+{
+public:
+ QString start() override
+ {
+ ++startCount;
+ running = true;
+ return url;
+ }
+
+ void stop() override
+ {
+ ++stopCount;
+ running = false;
+ }
+
+ QString serverName() const override { return QStringLiteral("qodeassist"); }
+
+ QString url = QStringLiteral("http://127.0.0.1:54321/mcp");
+ int startCount = 0;
+ int stopCount = 0;
+ bool running = false;
+};
+
+} // namespace
+
+void AcpChatBackendTest::testAcpBackendStreamsTextAndThinking()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setThoughtChunks({"pondering"});
+ agent->setReplyChunks({"Hello", " world"});
+ };
+
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.eventsOfType().size(), 1);
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+
+ const auto deltas = fixture.eventsOfType();
+ QCOMPARE(deltas.size(), 2);
+ QCOMPARE(deltas.at(0).text, QString("Hello"));
+ QCOMPARE(deltas.at(1).text, QString(" world"));
+
+ const auto thinking = fixture.eventsOfType();
+ QCOMPARE(thinking.size(), 1);
+ QCOMPARE(thinking.at(0).text, QString("pondering"));
+
+ const QString turnId = fixture.eventsOfType().at(0).turnId;
+ QVERIFY(!turnId.isEmpty());
+ QCOMPARE(deltas.at(0).turnId, turnId);
+ QCOMPARE(fixture.eventsOfType().at(0).turnId, turnId);
+
+ QCOMPARE(fixture.agent->prompts().size(), 1);
+ QCOMPARE(fixture.agent->prompts().at(0).size(), 1);
+ QCOMPARE(
+ fixture.agent->prompts().at(0).at(0).toObject().value("text").toString(), QString("hi"));
+}
+
+void AcpChatBackendTest::testAcpBackendStartsSessionInWorkingDirectory()
+{
+ AcpBackendFixture fixture;
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.requestedCwd, Acp::agentWorkingDirectory());
+ QCOMPARE(fixture.agent->newSessionCwd(), Acp::agentWorkingDirectory());
+ QVERIFY(!fixture.agent->newSessionCwd().isEmpty());
+ QCOMPARE(fixture.agent->clientInfo().value("name").toString(), QString("QodeAssistTest"));
+}
+
+void AcpChatBackendTest::testAcpBackendAuthenticatesWhenTheAgentAsksForIt()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setRequireAuthentication(true); };
+
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+ QCOMPARE(fixture.agent->authMethodUsed(), QString("login"));
+ QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 2);
+}
+
+void AcpChatBackendTest::testAcpBackendReportsPromptFailure()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setPromptFailure("agent exploded"); };
+
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+ QVERIFY(fixture.eventsOfType().at(0).error.contains("agent exploded"));
+ QCOMPARE(
+ fixture.eventsOfType().at(0).turnId,
+ fixture.eventsOfType().at(0).turnId);
+}
+
+void AcpChatBackendTest::testAcpBackendCancelInterruptsTheTurn()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setHangOnPrompt(true);
+ agent->setReplyChunks({"partial"});
+ };
+
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ fixture.backend.cancel();
+
+ QTRY_VERIFY(fixture.agent->wasCancelled());
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+}
+
+void AcpChatBackendTest::testAcpBackendSendsAttachmentsAsEmbeddedResources()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); };
+ fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk"));
+
+ fixture.sendBlocks(
+ {Session::TextBlock{"summarise this"},
+ Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}});
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const auto resources = fixture.promptBlocksOfType("resource");
+ QCOMPARE(resources.size(), 1);
+
+ const QJsonObject resource = resources.at(0).value("resource").toObject();
+ QCOMPARE(resource.value("text").toString(), QString("remember the milk"));
+ QCOMPARE(resource.value("mimeType").toString(), QString("text/plain"));
+ QVERIFY(resource.value("uri").toString().endsWith("notes.txt"));
+
+ QCOMPARE(fixture.promptBlocksOfType("text").size(), 1);
+}
+
+void AcpChatBackendTest::testAcpBackendInlinesAttachmentsWithoutEmbeddedContextSupport()
+{
+ AcpBackendFixture fixture;
+ fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk"));
+
+ fixture.sendBlocks(
+ {Session::TextBlock{"summarise this"},
+ Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}});
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.promptBlocksOfType("resource").size(), 0);
+
+ const auto texts = fixture.promptBlocksOfType("text");
+ QCOMPARE(texts.size(), 2);
+
+ const QString inlined = texts.at(1).value("text").toString();
+ QVERIFY(inlined.contains("notes.txt"));
+ QVERIFY(inlined.contains("remember the milk"));
+}
+
+void AcpChatBackendTest::testAcpBackendReportsAttachmentsItCannotLoad()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); };
+
+ fixture.sendBlocks(
+ {Session::TextBlock{"summarise this"},
+ Session::AttachmentBlock{"notes.txt", "missing.txt"}});
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.promptBlocksOfType("resource").size(), 0);
+
+ const auto texts = fixture.promptBlocksOfType("text");
+ QCOMPARE(texts.size(), 2);
+ QVERIFY(texts.at(1).value("text").toString().contains("notes.txt"));
+}
+
+void AcpChatBackendTest::testAcpBackendSendsImagesWhenTheAgentAcceptsThem()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsImages(true); };
+ fixture.storage.insert("shot_cd34.png", QByteArray("\x89PNG-bytes"));
+
+ fixture.sendBlocks(
+ {Session::TextBlock{"what is this"},
+ Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}});
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const auto images = fixture.promptBlocksOfType("image");
+ QCOMPARE(images.size(), 1);
+ QCOMPARE(images.at(0).value("mimeType").toString(), QString("image/png"));
+ QCOMPARE(
+ images.at(0).value("data").toString(),
+ QString::fromLatin1(QByteArray("\x89PNG-bytes").toBase64()));
+}
+
+void AcpChatBackendTest::testAcpBackendNamesImagesTheAgentCannotAccept()
+{
+ AcpBackendFixture fixture;
+ fixture.storage.insert("shot_cd34.png", QByteArray("\x89PNG-bytes"));
+
+ fixture.sendBlocks(
+ {Session::TextBlock{"what is this"},
+ Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}});
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.promptBlocksOfType("image").size(), 0);
+
+ const auto texts = fixture.promptBlocksOfType("text");
+ QCOMPARE(texts.size(), 2);
+ QVERIFY(texts.at(1).value("text").toString().contains("shot.png"));
+}
+
+void AcpChatBackendTest::testAcpBackendFencesAttachmentsThatContainFences()
+{
+ AcpBackendFixture fixture;
+ fixture.storage.insert("readme_ab12.md", QByteArray("intro\n```\ncode\n```\noutro"));
+
+ fixture.sendBlocks(
+ {Session::TextBlock{"read it"}, Session::AttachmentBlock{"readme.md", "readme_ab12.md"}});
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const auto texts = fixture.promptBlocksOfType("text");
+ QCOMPARE(texts.size(), 2);
+
+ const QString inlined = texts.at(1).value("text").toString();
+ QVERIFY(inlined.startsWith("File: readme.md\n````\n"));
+ QVERIFY(inlined.endsWith("\n````"));
+ QVERIFY(inlined.contains("```\ncode\n```"));
+}
+
+void AcpChatBackendTest::testAcpBackendTruncatesOversizedAttachments()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); };
+
+ const QByteArray huge(700 * 1024, 'x');
+ fixture.storage.insert("dump_ab12.log", huge);
+
+ fixture.sendBlocks(
+ {Session::TextBlock{"summarise"}, Session::AttachmentBlock{"dump.log", "dump_ab12.log"}});
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const auto resources = fixture.promptBlocksOfType("resource");
+ QCOMPARE(resources.size(), 1);
+
+ const QString text = resources.at(0).value("resource").toObject().value("text").toString();
+ QVERIFY(text.size() < huge.size());
+ QVERIFY(text.contains("truncated by QodeAssist"));
+}
+
+void AcpChatBackendTest::testAcpBackendRejectsAnEmptyTurn()
+{
+ AcpBackendFixture fixture;
+
+ fixture.sendBlocks({Session::TextBlock{""}});
+
+ QCOMPARE(fixture.eventsOfType().size(), 1);
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+ QVERIFY(fixture.agent == nullptr);
+}
+
+void AcpChatBackendTest::testAcpBackendSendsAttachmentsWithoutAnyMessageText()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); };
+ fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk"));
+
+ fixture.sendBlocks(
+ {Session::TextBlock{""}, Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}});
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+ QCOMPARE(fixture.promptBlocksOfType("resource").size(), 1);
+}
+
+void AcpChatBackendTest::testAcpBackendRaisesAgentPermissionRequests()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
+ };
+
+ fixture.send("edit main.cpp");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const Session::PermissionRequested request
+ = fixture.eventsOfType().at(0);
+ QCOMPARE(request.turnId, fixture.eventsOfType().at(0).turnId);
+ QVERIFY(!request.requestId.isEmpty());
+ QCOMPARE(request.toolCallId, QString("call-1"));
+ QCOMPARE(request.title, QString("Write main.cpp"));
+ QCOMPARE(request.toolKind, QString("edit"));
+ QCOMPARE(request.options.size(), 2);
+ QCOMPARE(request.options.at(0), (Session::PermissionOption{"allow", "Allow", "allow_once"}));
+ QCOMPARE(request.options.at(1), (Session::PermissionOption{"deny", "Deny", "reject_once"}));
+
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+ QVERIFY(fixture.agent->permissionOutcomes().isEmpty());
+}
+
+void AcpChatBackendTest::testAcpBackendSendsThePermissionAnswerToTheAgent()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
+ };
+
+ fixture.send("edit main.cpp");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const QString requestId = fixture.eventsOfType().at(0).requestId;
+ fixture.backend.respondPermission(requestId, "allow");
+
+ QTRY_COMPARE(fixture.agent->permissionOutcomes().size(), 1);
+ QCOMPARE(
+ fixture.agent->permissionOutcomes().at(0).value("outcome").toString(), QString("selected"));
+ QCOMPARE(fixture.agent->permissionOutcomes().at(0).value("optionId").toString(), QString("allow"));
+
+ const auto resolutions = fixture.eventsOfType();
+ QCOMPARE(resolutions.size(), 1);
+ QCOMPARE(resolutions.at(0).requestId, requestId);
+ QCOMPARE(resolutions.at(0).optionId, QString("allow"));
+ QVERIFY(!resolutions.at(0).cancelled);
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+}
+
+void AcpChatBackendTest::testAcpBackendCancelsOutstandingPermissionRequests()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
+ };
+
+ fixture.send("edit main.cpp");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const QString requestId = fixture.eventsOfType().at(0).requestId;
+ fixture.backend.cancel();
+
+ QTRY_COMPARE(fixture.agent->permissionOutcomes().size(), 1);
+ QCOMPARE(
+ fixture.agent->permissionOutcomes().at(0).value("outcome").toString(), QString("cancelled"));
+
+ const auto resolutions = fixture.eventsOfType();
+ QCOMPARE(resolutions.size(), 1);
+ QCOMPARE(resolutions.at(0).requestId, requestId);
+ QVERIFY(resolutions.at(0).cancelled);
+ QVERIFY(resolutions.at(0).optionId.isEmpty());
+
+ QTRY_VERIFY(fixture.agent->wasCancelled());
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+}
+
+void AcpChatBackendTest::testAcpBackendSeedsAHandoverSummaryOnce()
+{
+ AcpBackendFixture fixture;
+
+ fixture.backend.setHandoverSummary("Earlier the user asked about parsing.");
+
+ fixture.send("carry on");
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const QList first = fixture.promptBlocksOfType("text");
+ QCOMPARE(first.size(), 2);
+ QVERIFY(first.at(0).value("text").toString().contains("Earlier the user asked about parsing."));
+ QVERIFY(first.at(0).value("text").toString().contains("summary"));
+ QCOMPARE(first.at(1).value("text").toString(), QString("carry on"));
+
+ fixture.send("and now this");
+ QTRY_COMPARE(fixture.eventsOfType().size(), 2);
+
+ const QList second = fixture.promptBlocksOfType("text");
+ QCOMPARE(second.size(), 1);
+ QCOMPARE(second.at(0).value("text").toString(), QString("and now this"));
+}
+
+void AcpChatBackendTest::testAcpBackendDropsAHandoverSummaryAfterACancelledTurn()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setHangOnPrompt(true); };
+
+ fixture.backend.setHandoverSummary("Earlier context.");
+
+ fixture.send("carry on");
+ QTRY_COMPARE(fixture.agent->prompts().size(), 1);
+
+ QVERIFY(
+ fixture.agent->prompts().at(0).at(0).toObject().value("text").toString().contains(
+ "Earlier context."));
+
+ fixture.backend.cancel();
+
+ fixture.configure = [](FakeAcpAgent *) {};
+ fixture.send("actually this");
+
+ QTRY_COMPARE(fixture.agent->prompts().size(), 2);
+
+ const QJsonArray second = fixture.agent->prompts().at(1);
+ QCOMPARE(second.size(), 1);
+ QCOMPARE(second.at(0).toObject().value("text").toString(), QString("actually this"));
+}
+
+void AcpChatBackendTest::testAcpBackendDropsAHandoverSummaryWithTheConversation()
+{
+ AcpBackendFixture fixture;
+
+ fixture.backend.setHandoverSummary("stale summary");
+ fixture.backend.clearToolSession(QString());
+
+ fixture.send("fresh start");
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const QList texts = fixture.promptBlocksOfType("text");
+ QCOMPARE(texts.size(), 1);
+ QCOMPARE(texts.at(0).value("text").toString(), QString("fresh start"));
+}
+
+void AcpChatBackendTest::testAcpBackendOffersKnowledgeToHttpCapableAgents()
+{
+ AcpBackendFixture fixture;
+ FakeKnowledgeService knowledge;
+ fixture.backend.setKnowledgeService(&knowledge);
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); };
+
+ fixture.send("what am I looking at");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(knowledge.startCount, 1);
+ QVERIFY(knowledge.running);
+
+ const QJsonArray offered = fixture.agent->offeredMcpServers();
+ QCOMPARE(offered.size(), 1);
+ QCOMPARE(offered.at(0).toObject().value("name").toString(), QString("qodeassist"));
+ QCOMPARE(offered.at(0).toObject().value("type").toString(), QString("http"));
+ QCOMPARE(offered.at(0).toObject().value("url").toString(), knowledge.url);
+}
+
+void AcpChatBackendTest::testAcpBackendWithholdsKnowledgeFromAgentsWithoutHttpMcp()
+{
+ AcpBackendFixture fixture;
+ FakeKnowledgeService knowledge;
+ fixture.backend.setKnowledgeService(&knowledge);
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(false); };
+
+ fixture.send("what am I looking at");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(knowledge.startCount, 0);
+ QVERIFY(!knowledge.running);
+ QVERIFY(fixture.agent->offeredMcpServers().isEmpty());
+}
+
+void AcpChatBackendTest::testAcpBackendStopsTheKnowledgeServerWithTheSession()
+{
+ AcpBackendFixture fixture;
+ FakeKnowledgeService knowledge;
+ fixture.backend.setKnowledgeService(&knowledge);
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); };
+
+ fixture.send("hello");
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+ QVERIFY(knowledge.running);
+
+ fixture.backend.clearToolSession(QString());
+
+ QCOMPARE(knowledge.stopCount, 1);
+ QVERIFY(!knowledge.running);
+}
+
+void AcpChatBackendTest::testAcpBackendSurvivesAKnowledgeServerThatWillNotStart()
+{
+ AcpBackendFixture fixture;
+ FakeKnowledgeService knowledge;
+ knowledge.url.clear();
+ fixture.backend.setKnowledgeService(&knowledge);
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); };
+
+ fixture.send("hello");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(knowledge.startCount, 1);
+ QVERIFY(fixture.agent->offeredMcpServers().isEmpty());
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+}
+
+void AcpChatBackendTest::testAcpBackendResumesAPersistedSession()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); };
+
+ fixture.backend.resumeSession("previous-session");
+ QVERIFY(fixture.backend.isResumePending());
+
+ fixture.send("carry on");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.agent->loadedSessionId(), QString("previous-session"));
+ QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0);
+ QCOMPARE(fixture.backend.acpSessionId(), QString("previous-session"));
+ QVERIFY(!fixture.backend.isResumePending());
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+}
+
+void AcpChatBackendTest::testAcpBackendReportsAnUnresumableSession()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setSupportsLoadSession(true);
+ agent->setLoadSessionError("session expired");
+ };
+
+ QStringList unavailable;
+ QObject::connect(
+ &fixture.backend,
+ &Acp::AcpChatBackend::agentSessionUnavailable,
+ &fixture.backend,
+ [&unavailable](const QString &reason) { unavailable.append(reason); });
+
+ fixture.backend.resumeSession("previous-session");
+ fixture.send("carry on");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(unavailable.size(), 1);
+ QVERIFY(unavailable.at(0).contains("session expired"));
+ QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0);
+ QVERIFY(fixture.backend.acpSessionId().isEmpty());
+ QVERIFY(!fixture.backend.isResumePending());
+ QVERIFY(fixture.eventsOfType().at(0).error.contains("read-only"));
+}
+
+void AcpChatBackendTest::testAcpBackendRefusesToResumeWithoutAgentSupport()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(false); };
+
+ QStringList unavailable;
+ QObject::connect(
+ &fixture.backend,
+ &Acp::AcpChatBackend::agentSessionUnavailable,
+ &fixture.backend,
+ [&unavailable](const QString &reason) { unavailable.append(reason); });
+
+ fixture.backend.resumeSession("previous-session");
+ fixture.send("carry on");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(unavailable.size(), 1);
+ QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/load")), 0);
+ QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0);
+ QVERIFY(fixture.backend.acpSessionId().isEmpty());
+}
+
+void AcpChatBackendTest::testAcpBackendStartsFreshAfterAFailedResume()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setSupportsLoadSession(true);
+ agent->setLoadSessionError("session expired");
+ };
+
+ fixture.backend.resumeSession("previous-session");
+ fixture.send("carry on");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ fixture.backend.startFreshSession();
+ fixture.send("start over");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1);
+ QCOMPARE(fixture.backend.acpSessionId(), QString("fake-session"));
+ QCOMPARE(fixture.agent->prompts().size(), 1);
+ QCOMPARE(
+ fixture.agent->prompts().at(0).at(0).toObject().value("text").toString(),
+ QString("start over"));
+}
+
+void AcpChatBackendTest::testAgentBindingKeepsTheResumeTargetBeforeTheFirstTurn()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); };
+
+ QVERIFY(fixture.backend.bindingSessionId().isEmpty());
+
+ fixture.backend.resumeSession("sess-from-file");
+ QCOMPARE(fixture.backend.bindingSessionId(), QString("sess-from-file"));
+ QVERIFY(fixture.backend.acpSessionId().isEmpty());
+
+ fixture.send("carry on");
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ QCOMPARE(fixture.backend.acpSessionId(), QString("sess-from-file"));
+ QCOMPARE(fixture.backend.bindingSessionId(), QString("sess-from-file"));
+}
+
+void AcpChatBackendTest::testAcpBackendCarriesTheLiveSessionIntoAResume()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); };
+
+ fixture.send("hello");
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+ QCOMPARE(fixture.backend.acpSessionId(), QString("fake-session"));
+
+ fixture.backend.setChatFilePath("/somewhere/else.json");
+ QCOMPARE(fixture.backend.bindingSessionId(), QString("fake-session"));
+
+ fixture.backend.clearToolSession(QString());
+ QVERIFY(fixture.backend.bindingSessionId().isEmpty());
+}
+
+void AcpChatBackendTest::testAcpBackendEstablishesTheSessionOnlyOnce()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) { agent->setHangOnNewSession(true); };
+
+ fixture.send("first");
+ QTRY_COMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1);
+
+ fixture.backend.cancel();
+ fixture.send("second");
+ QTest::qWait(50);
+
+ QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1);
+ QCOMPARE(fixture.agent->prompts().size(), 0);
+}
+
+void AcpChatBackendTest::testAcpBackendMapsToolCallLifecycle()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setToolCallUpdates(
+ {QJsonObject{
+ {"toolCallId", QStringLiteral("call-1")},
+ {"title", QStringLiteral("Edit main.cpp")},
+ {"kind", QStringLiteral("edit")},
+ {"status", QStringLiteral("pending")}},
+ QJsonObject{
+ {"toolCallId", QStringLiteral("call-1")},
+ {"status", QStringLiteral("completed")},
+ {"locations",
+ QJsonArray{QJsonObject{{"path", QStringLiteral("/p/main.cpp")}, {"line", 12}}}},
+ {"content",
+ QJsonArray{
+ QJsonObject{
+ {"type", QStringLiteral("diff")},
+ {"path", QStringLiteral("/p/main.cpp")},
+ {"oldText", QStringLiteral("int a;")},
+ {"newText", QStringLiteral("int b;")}}}}}});
+ };
+
+ fixture.send("edit it");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const auto updates = fixture.eventsOfType();
+ QCOMPARE(updates.size(), 2);
+
+ QCOMPARE(updates.at(0).toolId, QString("call-1"));
+ QCOMPARE(updates.at(0).name, QString("Edit main.cpp"));
+ QCOMPARE(updates.at(0).kind, QString("edit"));
+ QCOMPARE(updates.at(0).status, QString("pending"));
+ QVERIFY(updates.at(0).details.isEmpty());
+ QVERIFY(updates.at(0).fromAgent);
+ QVERIFY(updates.at(1).fromAgent);
+
+ QCOMPARE(updates.at(1).status, QString("completed"));
+ QCOMPARE(updates.at(1).name, QString("Edit main.cpp"));
+
+ const QJsonArray locations = updates.at(1).details.value("locations").toArray();
+ QCOMPARE(locations.size(), 1);
+ QCOMPARE(locations.at(0).toObject().value("path").toString(), QString("/p/main.cpp"));
+ QCOMPARE(locations.at(0).toObject().value("line").toInt(), 12);
+
+ const QJsonArray diffs = updates.at(1).details.value("diffs").toArray();
+ QCOMPARE(diffs.size(), 1);
+ QCOMPARE(diffs.at(0).toObject().value("oldText").toString(), QString("int a;"));
+ QCOMPARE(diffs.at(0).toObject().value("newText").toString(), QString("int b;"));
+}
+
+void AcpChatBackendTest::testAcpBackendMapsPlanUpdates()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setPlanUpdates(
+ {QJsonArray{
+ QJsonObject{
+ {"content", QStringLiteral("Read the file")},
+ {"priority", QStringLiteral("high")},
+ {"status", QStringLiteral("in_progress")}},
+ QJsonObject{
+ {"content", QStringLiteral("Apply the fix")},
+ {"priority", QStringLiteral("medium")},
+ {"status", QStringLiteral("pending")}}},
+ QJsonArray{
+ QJsonObject{
+ {"content", QStringLiteral("Read the file")},
+ {"priority", QStringLiteral("high")},
+ {"status", QStringLiteral("completed")}},
+ QJsonObject{
+ {"content", QStringLiteral("Apply the fix")},
+ {"priority", QStringLiteral("medium")},
+ {"status", QStringLiteral("in_progress")}}}});
+ };
+
+ fixture.send("fix it");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const auto plans = fixture.eventsOfType();
+ QCOMPARE(plans.size(), 2);
+ QCOMPARE(plans.at(0).entries.size(), 2);
+ QCOMPARE(
+ plans.at(0).entries.at(0),
+ (Session::PlanEntry{"Read the file", "high", "in_progress"}));
+ QCOMPARE(
+ plans.at(1).entries.at(0), (Session::PlanEntry{"Read the file", "high", "completed"}));
+}
+
+void AcpChatBackendTest::testAcpBackendReportsUsageFromThePromptResult()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setPromptUsage(
+ QJsonObject{
+ {"cachedReadTokens", 23119},
+ {"cachedWriteTokens", 12271},
+ {"inputTokens", 2},
+ {"outputTokens", 13},
+ {"totalTokens", 35405}});
+ };
+
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const auto usage = fixture.eventsOfType();
+ QCOMPARE(usage.size(), 1);
+ QCOMPARE(usage.at(0).usage.promptTokens, 2);
+ QCOMPARE(usage.at(0).usage.completionTokens, 13);
+ QCOMPARE(usage.at(0).usage.cachedPromptTokens, 23119);
+ QCOMPARE(usage.at(0).usage.reasoningTokens, 0);
+}
+
+void AcpChatBackendTest::testAcpBackendIgnoresTheCumulativeContextGauge()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setContextGauge(
+ QJsonObject{
+ {"size", 967000},
+ {"used", 35395},
+ {"cost", QJsonObject{{"amount", 0.0807}, {"currency", QStringLiteral("USD")}}}});
+ };
+
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+}
+
+void AcpChatBackendTest::testAcpBackendForwardsTheAgentSuggestedTitle()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setSuggestedTitle(" Refactor\nthe parser ");
+ };
+
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+ QCOMPARE(
+ fixture.eventsOfType().at(0).title,
+ QString("Refactor the parser"));
+}
+
+void AcpChatBackendTest::testAcpBackendDeclinesAmbiguousPermissionOptions()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setPermissionRequest(
+ writeToolCall(),
+ QJsonArray{
+ QJsonObject{
+ {"optionId", QStringLiteral("x")},
+ {"name", QStringLiteral("Yes, and don't ask again")},
+ {"kind", QStringLiteral("allow_always")}},
+ QJsonObject{
+ {"optionId", QStringLiteral("x")},
+ {"name", QStringLiteral("No")},
+ {"kind", QStringLiteral("reject_once")}}});
+ };
+
+ fixture.send("edit main.cpp");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const auto requests = fixture.eventsOfType();
+ QCOMPARE(requests.size(), 1);
+ QVERIFY(requests.at(0).options.isEmpty());
+
+ QVERIFY(fixture.eventsOfType().at(0).cancelled);
+ QTRY_COMPARE(fixture.agent->permissionOutcomes().size(), 1);
+ QCOMPARE(
+ fixture.agent->permissionOutcomes().at(0).value("outcome").toString(), QString("cancelled"));
+}
+
+void AcpChatBackendTest::testAcpBackendClampsOversizedPermissionText()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ QJsonObject toolCall = writeToolCall();
+ toolCall["title"] = QString(5000, QChar('A'));
+ agent->setPermissionRequest(toolCall, writePermissionOptions());
+ };
+
+ fixture.send("edit main.cpp");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const Session::PermissionRequested request
+ = fixture.eventsOfType().at(0);
+ QVERIFY(request.title.size() < 5000);
+ QVERIFY(request.title.endsWith(QChar(0x2026)));
+ QCOMPARE(request.options.size(), 2);
+}
+
+void AcpChatBackendTest::testAcpBackendCancelsPermissionsWhenTheTurnCompletes()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
+ agent->setFinishPromptWithoutWaiting(true);
+ };
+
+ fixture.send("edit main.cpp");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ const auto resolutions = fixture.eventsOfType();
+ QCOMPARE(resolutions.size(), 1);
+ QVERIFY(resolutions.at(0).cancelled);
+
+ const QString requestId = fixture.eventsOfType().at(0).requestId;
+ QVERIFY(!fixture.backend.respondPermission(requestId, "allow"));
+}
+
+void AcpChatBackendTest::testAcpBackendCancelsPermissionRequestsWhenTheTurnFails()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setPermissionRequest(writeToolCall(), writePermissionOptions());
+ };
+
+ fixture.send("edit main.cpp");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+
+ fixture.backend.clearToolSession(QString());
+
+ QCOMPARE(fixture.eventsOfType().size(), 1);
+ QVERIFY(fixture.eventsOfType().at(0).cancelled);
+}
+
+void AcpChatBackendTest::testAcpBackendTracksAgentSlashCommands()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setAvailableCommandUpdates(
+ {QJsonArray{
+ QJsonObject{
+ {"name", QStringLiteral("help")},
+ {"description", QStringLiteral("Show help")},
+ {"input", QJsonObject{{"hint", QStringLiteral("topic")}}}},
+ QJsonObject{
+ {"name", QStringLiteral("review")},
+ {"description", QStringLiteral("Review\nthe diff")}}},
+ QJsonArray{
+ QJsonObject{
+ {"name", QStringLiteral("help")},
+ {"description", QStringLiteral("Show help")}}}});
+ };
+
+ int changes = 0;
+ QObject::connect(
+ &fixture.backend,
+ &Acp::AcpChatBackend::availableCommandsChanged,
+ &fixture.backend,
+ [&changes] { ++changes; });
+
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+ QTRY_COMPARE(fixture.backend.availableCommands().size(), 1);
+ QCOMPARE(fixture.backend.availableCommands().at(0).name, QString("help"));
+ QCOMPARE(changes, 2);
+
+ fixture.backend.clearToolSession("chat.json");
+ QCOMPARE(fixture.backend.availableCommands().size(), 0);
+ QCOMPARE(changes, 3);
+}
+
+void AcpChatBackendTest::testEarlyAgentCommandsSurviveEstablishment()
+{
+ AcpBackendFixture fixture;
+ fixture.configure = [](FakeAcpAgent *agent) {
+ agent->setCommandsBeforeSessionReply(
+ QJsonArray{QJsonObject{
+ {"name", QStringLiteral("help")},
+ {"description", QStringLiteral("Show help")}}});
+ };
+
+ fixture.send("hi");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+ QCOMPARE(fixture.backend.availableCommands().size(), 1);
+ QCOMPARE(fixture.backend.availableCommands().at(0).name, QString("help"));
+}
+
+void AcpChatBackendTest::testAgentCommandInvocationSendsPlainPrompt()
+{
+ AcpBackendFixture fixture;
+
+ fixture.send("/review src/main.cpp");
+
+ QTRY_COMPARE(fixture.eventsOfType().size(), 1);
+ QCOMPARE(fixture.agent->prompts().size(), 1);
+
+ const QJsonArray prompt = fixture.agent->prompts().at(0);
+ QCOMPARE(prompt.size(), 1);
+ QCOMPARE(prompt.at(0).toObject().value("type").toString(), QString("text"));
+ QCOMPARE(
+ prompt.at(0).toObject().value("text").toString(), QString("/review src/main.cpp"));
+}
+
+} // namespace QodeAssist
diff --git a/tests/AcpChatBackendTest.hpp b/tests/AcpChatBackendTest.hpp
new file mode 100644
index 0000000..4828a30
--- /dev/null
+++ b/tests/AcpChatBackendTest.hpp
@@ -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
+
+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
diff --git a/tests/AcpCompletionEngineTest.cpp b/tests/AcpCompletionEngineTest.cpp
new file mode 100644
index 0000000..e184ebd
--- /dev/null
+++ b/tests/AcpCompletionEngineTest.cpp
@@ -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
+#include
+#include
+
+#include
+
+#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 {
+ 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;
+ 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
diff --git a/tests/AcpCompletionEngineTest.hpp b/tests/AcpCompletionEngineTest.hpp
new file mode 100644
index 0000000..84e0a89
--- /dev/null
+++ b/tests/AcpCompletionEngineTest.hpp
@@ -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
+
+namespace QodeAssist {
+
+class AcpCompletionEngineTest final : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testAcpCompletionFailsWithoutAgent();
+ void testAcpCompletionRoutesProposalToGhostText();
+ void testAcpCompletionOffersMcpServerToTheAgent();
+ void testAcpCompletionSupersedesInflightRequest();
+};
+
+} // namespace QodeAssist
diff --git a/tests/AgentBindingTest.cpp b/tests/AgentBindingTest.cpp
new file mode 100644
index 0000000..470291f
--- /dev/null
+++ b/tests/AgentBindingTest.cpp
@@ -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
+#include
+#include
+#include
+#include
+#include
+
+#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
diff --git a/tests/AgentBindingTest.hpp b/tests/AgentBindingTest.hpp
new file mode 100644
index 0000000..df441cf
--- /dev/null
+++ b/tests/AgentBindingTest.hpp
@@ -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
+
+namespace QodeAssist {
+
+class AgentBindingTest final : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testAgentBindingRejectsMalformedRecords();
+ void testMalformedAgentBindingLeavesTheChatUnbound();
+ void testAgentBindingRoundTripsThroughTheChatFile();
+ void testChatFileWithoutAnAgentLoadsUnbound();
+};
+
+} // namespace QodeAssist
diff --git a/tests/AgentCatalogTest.cpp b/tests/AgentCatalogTest.cpp
new file mode 100644
index 0000000..39e15cf
--- /dev/null
+++ b/tests/AgentCatalogTest.cpp
@@ -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
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#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
diff --git a/tests/AgentCatalogTest.hpp b/tests/AgentCatalogTest.hpp
new file mode 100644
index 0000000..06485ed
--- /dev/null
+++ b/tests/AgentCatalogTest.hpp
@@ -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
+
+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
diff --git a/tests/AgentKnowledgeServerTest.cpp b/tests/AgentKnowledgeServerTest.cpp
new file mode 100644
index 0000000..8d7cfa3
--- /dev/null
+++ b/tests/AgentKnowledgeServerTest.cpp
@@ -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
+#include
+#include
+
+#include
+
+#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(exposed.cbegin(), exposed.cend()),
+ (QSet{
+ "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
diff --git a/tests/AgentKnowledgeServerTest.hpp b/tests/AgentKnowledgeServerTest.hpp
new file mode 100644
index 0000000..ba3162a
--- /dev/null
+++ b/tests/AgentKnowledgeServerTest.hpp
@@ -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
+
+namespace QodeAssist {
+
+class AgentKnowledgeServerTest final : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testKnowledgeServerExposesOnlyReadOnlyTools();
+ void testKnowledgeServerBindsLoopbackOnAnEphemeralPort();
+};
+
+} // namespace QodeAssist
diff --git a/tests/AgenticCompletionEngineTest.cpp b/tests/AgenticCompletionEngineTest.cpp
new file mode 100644
index 0000000..9ec6d4a
--- /dev/null
+++ b/tests/AgenticCompletionEngineTest.cpp
@@ -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
+#include
+#include
+
+#include
+
+#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(
+ provider.fakeClient()->tools()->tool(Tools::ProposeCompletionTool::toolId()));
+ }
+
+ FakeLlmProvider provider;
+ FakeCompletionRegistry registry;
+ FakeChatPromptProvider promptProvider;
+ FakeCompletionDocumentReader reader;
+ FakePerformanceLogger performanceLogger;
+ QTextDocument document;
+ QSharedPointer 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
diff --git a/tests/AgenticCompletionEngineTest.hpp b/tests/AgenticCompletionEngineTest.hpp
new file mode 100644
index 0000000..27407fa
--- /dev/null
+++ b/tests/AgenticCompletionEngineTest.hpp
@@ -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
+
+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
diff --git a/tests/BlockCodecTest.cpp b/tests/BlockCodecTest.cpp
new file mode 100644
index 0000000..1ae1540
--- /dev/null
+++ b/tests/BlockCodecTest.cpp
@@ -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
+#include
+#include
+
+#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
diff --git a/tests/BlockCodecTest.hpp b/tests/BlockCodecTest.hpp
new file mode 100644
index 0000000..7779442
--- /dev/null
+++ b/tests/BlockCodecTest.hpp
@@ -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
+
+namespace QodeAssist {
+
+class BlockCodecTest final : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testBlockCodecRejectsForgedMarkers();
+ void testFileEditParsingRequiresTheMarkerAtTheStart();
+ void testPermissionOptionsCarryAllowsAcrossTheSeam();
+};
+
+} // namespace QodeAssist
diff --git a/tests/ChatFileStoreTest.cpp b/tests/ChatFileStoreTest.cpp
new file mode 100644
index 0000000..022b928
--- /dev/null
+++ b/tests/ChatFileStoreTest.cpp
@@ -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
+#include
+#include
+#include
+#include
+#include
+
+#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(history.at(0).blocks.at(0)),
+ (Session::TextBlock{"explain this"}));
+ QCOMPARE(history.at(1).role, Session::MessageRole::Assistant);
+}
+
+} // namespace QodeAssist
diff --git a/tests/ChatFileStoreTest.hpp b/tests/ChatFileStoreTest.hpp
new file mode 100644
index 0000000..16b721c
--- /dev/null
+++ b/tests/ChatFileStoreTest.hpp
@@ -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
+
+namespace QodeAssist {
+
+class ChatFileStoreTest final : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testChatFileStoreRoundTripsStoredContent();
+ void testLegacyChatFileLoadsThroughTheFileStore();
+};
+
+} // namespace QodeAssist
diff --git a/tests/ChatHistorySerializerTest.cpp b/tests/ChatHistorySerializerTest.cpp
new file mode 100644
index 0000000..4ec54d9
--- /dev/null
+++ b/tests/ChatHistorySerializerTest.cpp
@@ -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
+#include
+#include
+
+#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(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(assistant.blocks.at(0)),
+ (Session::ThinkingBlock{"weighing options", "sig-1", false}));
+ QCOMPARE(
+ std::get(assistant.blocks.at(1)),
+ (Session::ToolCallBlock{"t1", "read_file", QJsonObject{}, "int main() {}"}));
+ QCOMPARE(
+ std::get(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 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 rows{user, thinking, tool, assistant, fileEdit};
+
+ const Session::ConversationHistory history = Session::buildFromRows(rows);
+ QCOMPARE(history.size(), 2);
+
+ const QList 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 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::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::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::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::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 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
diff --git a/tests/ChatHistorySerializerTest.hpp b/tests/ChatHistorySerializerTest.hpp
new file mode 100644
index 0000000..dfcc75c
--- /dev/null
+++ b/tests/ChatHistorySerializerTest.hpp
@@ -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
+
+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
diff --git a/tests/ChatViewTest.cpp b/tests/ChatViewTest.cpp
new file mode 100644
index 0000000..65c5c89
--- /dev/null
+++ b/tests/ChatViewTest.cpp
@@ -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
+
+#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::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(), 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
diff --git a/tests/ChatViewTest.hpp b/tests/ChatViewTest.hpp
new file mode 100644
index 0000000..9bec0cf
--- /dev/null
+++ b/tests/ChatViewTest.hpp
@@ -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
+
+namespace QodeAssist {
+
+class ChatViewTest final : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testFileMentionSelectionFollowsChatToolsSetting();
+ void testChatModelExposesSessionRowsDirectly();
+};
+
+} // namespace QodeAssist
diff --git a/tests/ClaudeCacheControlTest.cpp b/tests/ClaudeCacheControlTest.cpp
new file mode 100644
index 0000000..ccc9c66
--- /dev/null
+++ b/tests/ClaudeCacheControlTest.cpp
@@ -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
+#include
+#include
+
+#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
diff --git a/tests/ClaudeCacheControlTest.hpp b/tests/ClaudeCacheControlTest.hpp
new file mode 100644
index 0000000..21c276a
--- /dev/null
+++ b/tests/ClaudeCacheControlTest.hpp
@@ -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
+#include
+
+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
diff --git a/tests/CodeHandlerTest.cpp b/tests/CodeHandlerTest.cpp
new file mode 100644
index 0000000..f598d73
--- /dev/null
+++ b/tests/CodeHandlerTest.cpp
@@ -0,0 +1,221 @@
+// Copyright (C) 2026 Petr Mironychev
+// Copyright (C) 2025 Povilas Kanapickas
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include "CodeHandlerTest.hpp"
+
+#include
+
+#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 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
diff --git a/tests/CodeHandlerTest.hpp b/tests/CodeHandlerTest.hpp
new file mode 100644
index 0000000..fa7609b
--- /dev/null
+++ b/tests/CodeHandlerTest.hpp
@@ -0,0 +1,37 @@
+// Copyright (C) 2026 Petr Mironychev
+// Copyright (C) 2025 Povilas Kanapickas
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#pragma once
+
+#include
+
+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
diff --git a/tests/CompletionTestSupport.hpp b/tests/CompletionTestSupport.hpp
new file mode 100644
index 0000000..32e7c83
--- /dev/null
+++ b/tests/CompletionTestSupport.hpp
@@ -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
+#include
+
+#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
diff --git a/tests/ConversationCoordinatorTest.cpp b/tests/ConversationCoordinatorTest.cpp
new file mode 100644
index 0000000..35b8126
--- /dev/null
+++ b/tests/ConversationCoordinatorTest.cpp
@@ -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
+#include
+
+#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 agentById(const QString &agentId) const override
+ {
+ const auto it = agents.constFind(agentId);
+ if (it == agents.constEnd())
+ return std::nullopt;
+ return *it;
+ }
+
+ QHash 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
diff --git a/tests/ConversationCoordinatorTest.hpp b/tests/ConversationCoordinatorTest.hpp
new file mode 100644
index 0000000..82a6bab
--- /dev/null
+++ b/tests/ConversationCoordinatorTest.hpp
@@ -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
+
+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
diff --git a/tests/DocumentContextReaderTest.cpp b/tests/DocumentContextReaderTest.cpp
new file mode 100644
index 0000000..a0715f5
--- /dev/null
+++ b/tests/DocumentContextReaderTest.cpp
@@ -0,0 +1,364 @@
+// Copyright (C) 2026 Petr Mironychev
+// Copyright (C) 2025 Povilas Kanapickas
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include "DocumentContextReaderTest.hpp"
+
+#include
+#include
+
+#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 DocumentContextReaderTest::createSettingsForWholeFile()
+{
+ auto settings = QSharedPointer::create();
+ settings->readFullFile.setValue(true, Utils::BaseAspect::BeQuiet);
+ return settings;
+}
+
+QSharedPointer DocumentContextReaderTest::createSettingsForLines(
+ int linesBefore, int linesAfter)
+{
+ auto settings = QSharedPointer::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
diff --git a/tests/DocumentContextReaderTest.hpp b/tests/DocumentContextReaderTest.hpp
new file mode 100644
index 0000000..c243813
--- /dev/null
+++ b/tests/DocumentContextReaderTest.hpp
@@ -0,0 +1,42 @@
+// Copyright (C) 2026 Petr Mironychev
+// Copyright (C) 2025 Povilas Kanapickas
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#pragma once
+
+#include
+#include
+#include
+
+#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 createSettingsForWholeFile();
+ QSharedPointer createSettingsForLines(
+ int linesBefore, int linesAfter);
+};
+
+} // namespace QodeAssist
diff --git a/tests/FakeLlmProvider.hpp b/tests/FakeLlmProvider.hpp
index c791875..eddb2f8 100644
--- a/tests/FakeLlmProvider.hpp
+++ b/tests/FakeLlmProvider.hpp
@@ -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> 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 {}; }
diff --git a/tests/FimCompletionEngineTest.cpp b/tests/FimCompletionEngineTest.cpp
new file mode 100644
index 0000000..c693ac2
--- /dev/null
+++ b/tests/FimCompletionEngineTest.cpp
@@ -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
+#include
+#include
+
+#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");
+};
+
+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;
+ 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("")));
+
+ 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("")));
+}
+
+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("")));
+}
+
+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
diff --git a/tests/FimCompletionEngineTest.hpp b/tests/FimCompletionEngineTest.hpp
new file mode 100644
index 0000000..6f6e2b9
--- /dev/null
+++ b/tests/FimCompletionEngineTest.hpp
@@ -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
+
+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
diff --git a/tests/LlmChatBackendTest.cpp b/tests/LlmChatBackendTest.cpp
new file mode 100644
index 0000000..8161a4f
--- /dev/null
+++ b/tests/LlmChatBackendTest.cpp
@@ -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
+#include
+#include
+#include
+
+#include
+
+#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();
+ return started.isEmpty() ? QString() : started.constLast().turnId;
+ }
+
+ template
+ QList eventsOfType() const
+ {
+ QList result;
+ for (const Session::SessionEvent &event : events) {
+ if (const auto *typed = std::get_if(&event))
+ result.append(*typed);
+ }
+ return result;
+ }
+
+ FakeChatPromptProvider promptProvider;
+ FakeLlmProvider provider;
+ Session::ConversationHistory history;
+ QList 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();
+ 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().size(), 1);
+ QCOMPARE(fixture.eventsOfType().at(0).turnId, turnId);
+ QCOMPARE(fixture.eventsOfType().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 &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().size(), 0);
+
+ fixture.backend.cancel();
+
+ emit fixture.provider.fakeClient()->chunkReceived(turnId, "late");
+ QCOMPARE(fixture.eventsOfType().size(), 0);
+ QCOMPARE(fixture.eventsOfType().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().size(), 1);
+ const Session::PermissionRequested request
+ = fixture.eventsOfType().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();
+ 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().size(), 2);
+ const QString second = fixture.eventsOfType().at(1).requestId;
+ QVERIFY(second != request.requestId);
+
+ QVERIFY(fixture.backend.respondPermission(second, "reject_once"));
+ QTRY_COMPARE(fixture.eventsOfType().size(), 2);
+ QCOMPARE(tool->executions, 1);
+
+ QTRY_VERIFY(
+ !fixture.eventsOfType().isEmpty()
+ && fixture.eventsOfType().constLast().toolId == QString("t2"));
+ const Session::ToolCallUpdated declined
+ = fixture.eventsOfType().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().size(), 1);
+ QString requestId = fixture.eventsOfType().at(0).requestId;
+
+ emit fixture.provider.fakeClient()->requestCompleted(turnId, "done");
+
+ auto resolutions = fixture.eventsOfType();
+ 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().size(), 1);
+ requestId = fixture.eventsOfType().at(0).requestId;
+
+ emit fixture.provider.fakeClient()->requestFailed(turnId, "boom");
+
+ resolutions = fixture.eventsOfType();
+ QCOMPARE(resolutions.size(), 1);
+ QVERIFY(resolutions.at(0).cancelled);
+ QVERIFY(!fixture.backend.respondPermission(requestId, "allow_once"));
+ QCOMPARE(fixture.eventsOfType