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("clau‮de")}})); + + 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().size(), 1); + + fixture.events.clear(); + turnId = fixture.send(); + fixture.provider.toolsManager()->executeToolCall(turnId, "t3", "fake_write", {}); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + requestId = fixture.eventsOfType().at(0).requestId; + + fixture.backend.cancel(); + + resolutions = fixture.eventsOfType(); + QCOMPARE(resolutions.size(), 1); + QVERIFY(resolutions.at(0).cancelled); + QVERIFY(!fixture.backend.respondPermission(requestId, "allow_once")); + QCOMPARE(tool->executions, 0); +} + +} // namespace QodeAssist diff --git a/tests/LlmChatBackendTest.hpp b/tests/LlmChatBackendTest.hpp new file mode 100644 index 0000000..fc8b0a6 --- /dev/null +++ b/tests/LlmChatBackendTest.hpp @@ -0,0 +1,25 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +namespace QodeAssist { + +class LlmChatBackendTest final : public QObject +{ + Q_OBJECT + +private slots: + void testTurnLedgerTracksTheActiveTurn(); + void testTurnLedgerDrainsPermissionsOnTurnEnd(); + void testLlmBackendStreamsThroughAFakeClient(); + void testRenderHistoryKeepsToolExchangePairs(); + void testLlmBackendIgnoresEventsFromOtherRequests(); + void testLlmBackendPermissionRoundTrip(); + void testLlmBackendDrainsPermissionsWhenTheTurnEnds(); +}; + +} // namespace QodeAssist diff --git a/tests/LlmSuggestionTest.cpp b/tests/LlmSuggestionTest.cpp new file mode 100644 index 0000000..ff62278 --- /dev/null +++ b/tests/LlmSuggestionTest.cpp @@ -0,0 +1,88 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "LlmSuggestionTest.hpp" + +#include + +#include "completion/LLMSuggestion.hpp" + +namespace QodeAssist { + +void LlmSuggestionTest::testReplaceLengthEmptyRight() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", ""), 0); +} + +void LlmSuggestionTest::testReplaceLengthNoOverlap() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", "bar"), 0); +} + +void LlmSuggestionTest::testReplaceLengthSingleCharNoOverlap() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("a", "b"), 0); +} + +void LlmSuggestionTest::testReplaceLengthLcpExtendsRight() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", "myVariable"), 5); +} + +void LlmSuggestionTest::testReplaceLengthLcpPartialEngineCall() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()"), 7); +} + +void LlmSuggestionTest::testReplaceLengthLcpThenClosingTailExtendsFull() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};"), 9); +} + +void LlmSuggestionTest::testReplaceLengthClosingTailReplaceSemicolon() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("x;", ";"), 1); +} + +void LlmSuggestionTest::testReplaceLengthClosingTailReplaceParen() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")"), 1); +} + +void LlmSuggestionTest::testReplaceLengthClosingTailReplaceBrackets() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("[0]", "];"), 2); +} + +void LlmSuggestionTest::testReplaceLengthClosingTailReplaceBracesAndSemi() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};"), 3); +} + +void LlmSuggestionTest::testReplaceLengthClosingTailWithLeadingSpace() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};"), 3); +} + +void LlmSuggestionTest::testReplaceLengthClosingTailEqualsSemi() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("= 5;", ";"), 1); +} + +void LlmSuggestionTest::testReplaceLengthClosingTailNoMatchingClose() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};"), 0); +} + +void LlmSuggestionTest::testReplaceLengthRealCodeRightNoLcp() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", " = 5;"), 0); +} + +void LlmSuggestionTest::testReplaceLengthTrailingWhitespaceOnlyLeftAlone() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("code", " "), 0); +} + +} // namespace QodeAssist diff --git a/tests/LlmSuggestionTest.hpp b/tests/LlmSuggestionTest.hpp new file mode 100644 index 0000000..104fdc9 --- /dev/null +++ b/tests/LlmSuggestionTest.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 + +namespace QodeAssist { + +class LlmSuggestionTest final : public QObject +{ + Q_OBJECT + +private slots: + void testReplaceLengthEmptyRight(); + void testReplaceLengthNoOverlap(); + void testReplaceLengthSingleCharNoOverlap(); + void testReplaceLengthLcpExtendsRight(); + void testReplaceLengthLcpPartialEngineCall(); + void testReplaceLengthLcpThenClosingTailExtendsFull(); + void testReplaceLengthClosingTailReplaceSemicolon(); + void testReplaceLengthClosingTailReplaceParen(); + void testReplaceLengthClosingTailReplaceBrackets(); + void testReplaceLengthClosingTailReplaceBracesAndSemi(); + void testReplaceLengthClosingTailWithLeadingSpace(); + void testReplaceLengthClosingTailEqualsSemi(); + void testReplaceLengthClosingTailNoMatchingClose(); + void testReplaceLengthRealCodeRightNoLcp(); + void testReplaceLengthTrailingWhitespaceOnlyLeftAlone(); +}; + +} // namespace QodeAssist diff --git a/tests/QodeAssistTest.cpp b/tests/QodeAssistTest.cpp deleted file mode 100644 index cfbc62e..0000000 --- a/tests/QodeAssistTest.cpp +++ /dev/null @@ -1,5436 +0,0 @@ -// 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 "QodeAssistTest.hpp" - -#include "ChatView/ChatModel.hpp" -#include "ChatView/ChatFileStore.hpp" -#include "FakeAcpAgent.hpp" -#include "FakeLlmProvider.hpp" -#include "ChatView/ChatConfigurationController.hpp" -#include "ChatView/ConversationCoordinator.hpp" -#include "ChatView/FileMentionItem.hpp" -#include "ChatView/LlmChatBackend.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "session/BlockCodec.hpp" -#include "session/FileEditPayload.hpp" -#include "session/TurnLedger.hpp" -#include "acp/AcpChatBackend.hpp" -#include "acp/AgentBinding.hpp" -#include "acp/AgentKnowledgeService.hpp" -#include "mcp/AgentKnowledgeServer.hpp" -#include "tools/BuildProjectTool.hpp" -#include "tools/CreateNewFileTool.hpp" -#include "tools/EditFileTool.hpp" -#include "tools/ExecuteTerminalCommandTool.hpp" -#include "tools/EditorStateTools.hpp" -#include "tools/GetIssuesListTool.hpp" -#include "tools/ReadFileTool.hpp" - -#include -#include "acp/AgentCatalog.hpp" -#include "acp/AgentCatalogStore.hpp" -#include "acp/AgentLaunch.hpp" -#include "acp/AgentRegistryParser.hpp" -#include "acp/AgentSpawn.hpp" - -#include -#include "completion/CodeHandler.hpp" -#include "completion/LLMSuggestion.hpp" -#include "context/FileEditManager.hpp" -#include "llmcore/ContextData.hpp" -#include "providers/ClaudeCacheControl.hpp" -#include "session/FencedText.hpp" -#include "session/HistoryProjection.hpp" -#include "session/HistorySerializer.hpp" -#include "session/Session.hpp" -#include "session/TurnContextBuilder.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace QodeAssist { - -namespace { - -constexpr char kTestMimeType[] = "text/python"; -constexpr char kTestFilePath[] = "/path/to/file"; -constexpr char kTestFileContext[] - = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n"; - -Session::Message userTurn() -{ - Session::Message message; - message.role = Session::MessageRole::User; - message.id = "u1"; - message.blocks - = {Session::TextBlock{"explain this"}, - Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}, - Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}; - return message; -} - -Session::Message assistantTurn() -{ - Session::Message message; - message.role = Session::MessageRole::Assistant; - message.id = "r1"; - message.blocks - = {Session::ThinkingBlock{"weighing options", "sig-1", false}, - Session::ToolCallBlock{ - "t1", "read_file", QJsonObject{{"path", "main.cpp"}}, "int main() {}"}, - Session::TextBlock{"here is the answer"}}; - message.usage = Session::Usage{120, 40, 80, 12}; - return message; -} - -Session::ConversationHistory sampleHistory() -{ - Session::Message assistant = assistantTurn(); - assistant.blocks.append( - Session::FileEditBlock{"e1", "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\"}"}); - - Session::ConversationHistory history; - history.append(userTurn()); - history.append(assistant); - return history; -} - -Session::ConversationHistory historyWithoutFileEdits() -{ - Session::ConversationHistory history; - history.append(userTurn()); - history.append(assistantTurn()); - return history; -} - -class FakeChatBackend : public Session::ChatBackend -{ -public: - void sendTurn(const Session::TurnRequest &request) override - { - ++m_turnCount; - m_lastUserBlocks = request.userBlocks; - m_historySizeAtSend = request.history ? request.history->size() : -1; - m_lastContext = request.context; - } - - void cancel() override { ++m_cancelCount; } - - bool respondPermission(const QString &requestId, const QString &optionId) override - { - m_permissionAnswers.append({requestId, optionId}); - if (!m_acceptPermissionAnswers) - return false; - if (m_autoResolvePermissions) - emit sessionEvent(Session::PermissionResolved{m_turnId, requestId, optionId, false}); - return true; - } - - void script(const QList &events) - { - for (const Session::SessionEvent &scripted : events) { - if (const auto *started = std::get_if(&scripted)) - m_turnId = started->turnId; - emit sessionEvent(scripted); - } - } - - void setAutoResolvePermissions(bool automatic) { m_autoResolvePermissions = automatic; } - void setAcceptPermissionAnswers(bool accept) { m_acceptPermissionAnswers = accept; } - - QList> permissionAnswers() const { return m_permissionAnswers; } - - int turnCount() const { return m_turnCount; } - int cancelCount() const { return m_cancelCount; } - qsizetype historySizeAtSend() const { return m_historySizeAtSend; } - const QList &lastUserBlocks() const { return m_lastUserBlocks; } - const std::optional &lastContext() const { return m_lastContext; } - -private: - int m_turnCount = 0; - int m_cancelCount = 0; - bool m_autoResolvePermissions = true; - bool m_acceptPermissionAnswers = true; - qsizetype m_historySizeAtSend = -1; - QString m_turnId; - QList> m_permissionAnswers; - QList m_lastUserBlocks; - std::optional m_lastContext; -}; - -QList editPermissionOptions() -{ - return { - Session::PermissionOption{"opt-once", "Yes", "allow_once"}, - Session::PermissionOption{"opt-always", "Yes, and don't ask again", "allow_always"}, - Session::PermissionOption{"opt-no", "No", "reject_once"}}; -} - -Session::PermissionRequested editPermissionRequest( - const QString &turnId, const QString &requestId, const QString &toolKind = QStringLiteral("edit")) -{ - return Session::PermissionRequested{ - .turnId = turnId, - .requestId = requestId, - .toolCallId = "tool-" + requestId, - .title = "Edit main.cpp", - .toolKind = toolKind, - .options = editPermissionOptions()}; -} - -Session::PermissionBlock permissionRowAt(const Session::Session &session, int index) -{ - return Session::decodePermissionBlock(session.rows().at(index).content) - .value_or(Session::PermissionBlock{}); -} - -Session::ToolCallUpdated llmToolStarted( - const QString &turnId, - const QString &toolId, - const QString &name, - const QJsonObject &arguments = {}, - bool dropPrecedingText = false) -{ - return Session::ToolCallUpdated{ - .turnId = turnId, - .toolId = toolId, - .name = name, - .status = "in_progress", - .arguments = arguments, - .dropPrecedingText = dropPrecedingText}; -} - -Session::ToolCallUpdated llmToolCompleted( - const QString &turnId, const QString &toolId, const QString &name, const QString &result) -{ - return Session::ToolCallUpdated{ - .turnId = turnId, - .toolId = toolId, - .name = name, - .status = "completed", - .result = result}; -} - -QList scriptedTurn(const QString &turnId) -{ - return { - Session::TurnStarted{turnId}, - Session::ThinkingReceived{turnId, "weighing options", "sig-1", false}, - Session::TextDelta{turnId, "let me "}, - Session::TextDelta{turnId, "check"}, - llmToolStarted(turnId, "t1", "read_file", QJsonObject{{"path", "main.cpp"}}), - llmToolCompleted(turnId, "t1", "read_file", "int main() {}"), - Session::TextDelta{turnId, "here is the answer"}, - Session::UsageReported{turnId, Session::Usage{120, 40, 80, 12}}, - Session::TurnCompleted{turnId}}; -} - -class FakeProjectContext : public Session::IProjectContextPort -{ -public: - explicit FakeProjectContext(Session::ProjectInfo info) - : m_info(std::move(info)) - {} - - Session::ProjectInfo projectInfo() const override { return m_info; } - -private: - Session::ProjectInfo m_info; -}; - -class FakeSkillsContext : public Session::ISkillsContextPort -{ -public: - FakeSkillsContext(QString alwaysOn, QString catalog, QList skills) - : m_alwaysOn(std::move(alwaysOn)) - , m_catalog(std::move(catalog)) - , m_skills(std::move(skills)) - {} - - QString alwaysOnBodies() const override { return m_alwaysOn; } - QString catalogText() const override { return m_catalog; } - - std::optional findSkill(const QString &name) const override - { - for (const Session::InvokedSkill &skill : m_skills) { - if (skill.name == name) - return skill; - } - return std::nullopt; - } - -private: - QString m_alwaysOn; - QString m_catalog; - QList m_skills; -}; - -void attachModelToSession(Session::Session &session, Chat::ChatModel &model) -{ - QObject::connect( - &session, &Session::Session::rowsReset, &model, &Chat::ChatModel::resetMessages); - QObject::connect( - &session, &Session::Session::rowsAppended, &model, &Chat::ChatModel::appendMessages); - QObject::connect( - &session, &Session::Session::rowUpdated, &model, &Chat::ChatModel::updateMessage); - QObject::connect( - &session, &Session::Session::rowsRemoved, &model, &Chat::ChatModel::removeMessages); - model.resetMessages(session.rows()); -} - -Session::ProjectInfo sampleProject() -{ - Session::ProjectInfo info; - info.available = true; - info.displayName = "QodeAssist"; - info.sourceRoot = "/src/QodeAssist"; - info.buildDirectory = "/src/QodeAssist/build"; - return info; -} - -Session::ConversationHistory historyWithEveryRowKind() -{ - Session::Message system; - system.role = Session::MessageRole::System; - system.id = "s1"; - system.blocks = {Session::TextBlock{"system"}}; - - Session::Message user; - user.role = Session::MessageRole::User; - user.id = "u1"; - user.blocks = {Session::TextBlock{"question"}}; - - Session::Message assistant; - assistant.role = Session::MessageRole::Assistant; - assistant.id = "a1"; - assistant.blocks - = {Session::TextBlock{"answer"}, - Session::ThinkingBlock{"thinking", {}, false}, - Session::ToolCallBlock{"call-1", "read_file", {}, "int main() {}", {}, {}, {}, false}, - Session::ToolCallBlock{ - "call-2", "Edit main.cpp", {}, "done", "edit", "completed", {}, true}, - Session::FileEditBlock{"edit-1", "payload"}, - Session::PermissionBlock{"p1", "tool-p1", "Edit main.cpp", "edit", {}}, - Session::PlanBlock{{Session::PlanEntry{"Step", "high", "pending"}}}}; - - Session::ConversationHistory history; - history.append(system); - history.append(user); - history.append(assistant); - return history; -} - -QList projectedKinds() -{ - QList kinds; - for (const Session::MessageRow &row : Session::projectToRows(historyWithEveryRowKind())) - kinds.append(row.kind); - return kinds; -} - -QList survivingKinds(Session::RowAudience audience) -{ - QList kept; - for (const Session::MessageRow &row : Session::projectToRows(historyWithEveryRowKind())) { - if (Session::rowTreatmentFor(audience, row.kind) != Session::RowTreatment::Omit) - kept.append(row.kind); - } - return kept; -} - -} // namespace - -Context::DocumentContextReader QodeAssistTest::createReader(const QString &text) -{ - auto *document = new QTextDocument(this); - document->setPlainText(text); - return Context::DocumentContextReader(document, kTestMimeType, kTestFilePath); -} - -QSharedPointer QodeAssistTest::createSettingsForWholeFile() -{ - auto settings = QSharedPointer::create(); - settings->readFullFile.setValue(true, Utils::BaseAspect::BeQuiet); - return settings; -} - -QSharedPointer QodeAssistTest::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; -} - -QJsonObject QodeAssistTest::expectedEphemeral(bool extendedTtl) -{ - QJsonObject cacheControl{{"type", "ephemeral"}}; - if (extendedTtl) - cacheControl["ttl"] = "1h"; - return cacheControl; -} - -void QodeAssistTest::testProcessTextEmpty() -{ - QCOMPARE(CodeHandler::processText("", "/file.py"), QString("\n\n")); -} - -void QodeAssistTest::testProcessTextCommentsAroundCodeBlock() -{ - const QString input = "This is a comment\n" - "```python\nprint('Hello, world!')\n```\n" - "Another comment"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString("# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n")); -} - -void QodeAssistTest::testProcessTextWithPlainCodeBlockNoNewline() -{ - const QString input = "This is a comment\n" - "```print('Hello, world!')\n```\n" - "Another comment"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString("# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n")); -} - -void QodeAssistTest::testProcessTextWithPlainCodeBlockWithNewline() -{ - const QString input = "This is a comment\n" - "```\nprint('Hello, world!')\n```\n" - "Another comment"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString("# This is a comment\n\n\nprint('Hello, world!')\n# Another comment\n\n")); -} - -void QodeAssistTest::testProcessTextNoCommentsWithLanguageCodeBlock() -{ - const QString input = "```python\nprint('Hello, world!')\n```"; - - QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("print('Hello, world!')\n")); -} - -void QodeAssistTest::testProcessTextNoCommentsWithPlainCodeBlockNoNewline() -{ - const QString input = "```print('Hello, world!')\n```"; - - QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("print('Hello, world!')\n")); -} - -void QodeAssistTest::testProcessTextNoCommentsWithPlainCodeBlockWithNewline() -{ - const QString input = "```\nprint('Hello, world!')\n```"; - - QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("\nprint('Hello, world!')\n")); -} - -void QodeAssistTest::testProcessTextWithMultipleCodeBlocksDifferentLanguages() -{ - const QString input = "First comment\n```python\nprint('Block 1')\n" - "```\nMiddle comment\n" - "```cpp\ncout << \"Block 2\";\n```\n" - "Last comment"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString( - "# First comment\n\n" - "print('Block 1')\n" - "// Middle comment\n\n" - "cout << \"Block 2\";\n" - "// Last comment\n\n")); -} - -void QodeAssistTest::testProcessTextWithMultipleCodeBlocksSameLanguage() -{ - const QString input = "First comment\n```python\nprint('Block 1')\n" - "```\nMiddle comment\n" - "```python\nprint('Block 2')\n```\n" - "Last comment"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString( - "# First comment\n\n" - "print('Block 1')\n" - "# Middle comment\n\n" - "print('Block 2')\n" - "# Last comment\n\n")); -} - -void QodeAssistTest::testProcessTextWithMultiplePlainCodeBlocksWithNewline() -{ - const QString input = "First comment\n```\nprint('Block 1')\n" - "```\nMiddle comment\n" - "```\ncout << \"Block 2\";\n```\n" - "Last comment"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString( - "# First comment\n\n\n" - "print('Block 1')\n" - "# Middle comment\n\n\n" - "cout << \"Block 2\";\n" - "# Last comment\n\n")); -} - -void QodeAssistTest::testProcessTextWithMultiplePlainCodeBlocksWithoutNewline() -{ - const QString input = "First comment\n```print('Block 1')\n" - "```\nMiddle comment\n" - "```cout << \"Block 2\";\n```\n" - "Last comment"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString( - "# First comment\n\n" - "print('Block 1')\n" - "# Middle comment\n\n" - "cout << \"Block 2\";\n" - "# Last comment\n\n")); -} - -void QodeAssistTest::testProcessTextWithEmptyLines() -{ - const QString input - = "Comment with empty line\n\n```python\nprint('Hello')\n```\n\nAnother comment"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString( - "# Comment with empty line\n\n\n" - "print('Hello')\n\n" - "# Another comment\n\n")); -} - -void QodeAssistTest::testProcessTextPlainCodeBlockWithNewlineWithEmptyLines() -{ - const QString input = "Comment with empty line\n\n```\nprint('Hello')\n```\n\nAnother comment"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString( - "# Comment with empty line\n\n\n\n" - "print('Hello')\n\n" - "# Another comment\n\n")); -} - -void QodeAssistTest::testProcessTextWithoutCodeBlock() -{ - const QString input = "This is just a comment\nwith multiple lines"; - - QCOMPARE( - CodeHandler::processText(input, "/file.py"), - QString("# This is just a comment\n# with multiple lines\n\n")); -} - -void QodeAssistTest::testDetectLanguageFromLine() -{ - QCOMPARE(CodeHandler::detectLanguageFromLine("```python"), QString("python")); - QCOMPARE(CodeHandler::detectLanguageFromLine("```javascript"), QString("js")); - QCOMPARE(CodeHandler::detectLanguageFromLine("```cpp"), QString("c-like")); - QCOMPARE(CodeHandler::detectLanguageFromLine("``` ruby "), QString("ruby")); - QCOMPARE(CodeHandler::detectLanguageFromLine("```"), QString()); - QCOMPARE(CodeHandler::detectLanguageFromLine("``` "), QString()); -} - -void QodeAssistTest::testDetectLanguageFromExtension() -{ - QCOMPARE(CodeHandler::detectLanguageFromExtension("py"), QString("python")); - QCOMPARE(CodeHandler::detectLanguageFromExtension("js"), QString("js")); - QCOMPARE(CodeHandler::detectLanguageFromExtension("cpp"), QString("c-like")); - QCOMPARE(CodeHandler::detectLanguageFromExtension("hpp"), QString("c-like")); - QCOMPARE(CodeHandler::detectLanguageFromExtension("rb"), QString("ruby")); - QCOMPARE(CodeHandler::detectLanguageFromExtension("sh"), QString("shell")); - QCOMPARE(CodeHandler::detectLanguageFromExtension("unknown"), QString()); - QCOMPARE(CodeHandler::detectLanguageFromExtension(""), QString()); -} - -void QodeAssistTest::testCommentPrefixForDifferentLanguages() -{ - struct Case - { - QString input; - QString expected; - }; - - const QList cases{ - {"Comment\n```python\ncode\n```", "# Comment\n\ncode\n"}, - {"Comment\n```cpp\ncode\n```", "// Comment\n\ncode\n"}, - {"Comment\n```ruby\ncode\n```", "# Comment\n\ncode\n"}, - {"Comment\n```lua\ncode\n```", "-- Comment\n\ncode\n"}}; - - for (const auto &testCase : cases) - QCOMPARE(CodeHandler::processText(testCase.input, ""), testCase.expected); -} - -void QodeAssistTest::testHasCodeBlocks() -{ - QVERIFY(CodeHandler::hasCodeBlocks("text\n```python\ncode\n```")); - QVERIFY(!CodeHandler::hasCodeBlocks("just a plain comment\nwith two lines")); -} - -void QodeAssistTest::testReplaceLengthEmptyRight() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", ""), 0); -} - -void QodeAssistTest::testReplaceLengthNoOverlap() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", "bar"), 0); -} - -void QodeAssistTest::testReplaceLengthSingleCharNoOverlap() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("a", "b"), 0); -} - -void QodeAssistTest::testReplaceLengthLcpExtendsRight() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", "myVariable"), 5); -} - -void QodeAssistTest::testReplaceLengthLcpPartialEngineCall() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()"), 7); -} - -void QodeAssistTest::testReplaceLengthLcpThenClosingTailExtendsFull() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};"), 9); -} - -void QodeAssistTest::testReplaceLengthClosingTailReplaceSemicolon() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("x;", ";"), 1); -} - -void QodeAssistTest::testReplaceLengthClosingTailReplaceParen() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")"), 1); -} - -void QodeAssistTest::testReplaceLengthClosingTailReplaceBrackets() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("[0]", "];"), 2); -} - -void QodeAssistTest::testReplaceLengthClosingTailReplaceBracesAndSemi() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};"), 3); -} - -void QodeAssistTest::testReplaceLengthClosingTailWithLeadingSpace() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};"), 3); -} - -void QodeAssistTest::testReplaceLengthClosingTailEqualsSemi() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("= 5;", ";"), 1); -} - -void QodeAssistTest::testReplaceLengthClosingTailNoMatchingClose() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};"), 0); -} - -void QodeAssistTest::testReplaceLengthRealCodeRightNoLcp() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", " = 5;"), 0); -} - -void QodeAssistTest::testReplaceLengthTrailingWhitespaceOnlyLeftAlone() -{ - QCOMPARE(LLMSuggestion::calculateReplaceLength("code", " "), 0); -} - -void QodeAssistTest::testCacheControlBreakpointWithoutExtendedTtl() -{ - const QJsonObject cacheControl = Providers::ClaudeCacheControl::buildBreakpoint(false); - - QCOMPARE(cacheControl.value("type").toString(), QString("ephemeral")); - QVERIFY(!cacheControl.contains("ttl")); -} - -void QodeAssistTest::testCacheControlBreakpointWithExtendedTtl() -{ - const QJsonObject cacheControl = Providers::ClaudeCacheControl::buildBreakpoint(true); - - QCOMPARE(cacheControl.value("type").toString(), QString("ephemeral")); - QCOMPARE(cacheControl.value("ttl").toString(), QString("1h")); -} - -void QodeAssistTest::testCacheControlSystemAsStringWrappedIntoArray() -{ - QJsonObject request; - request["system"] = "you are a helpful agent"; - - Providers::ClaudeCacheControl::apply(request, false); - - QVERIFY(request.value("system").isArray()); - const QJsonArray system = request.value("system").toArray(); - QCOMPARE(system.size(), 1); - - const QJsonObject block = system.first().toObject(); - QCOMPARE(block.value("type").toString(), QString("text")); - QCOMPARE(block.value("text").toString(), QString("you are a helpful agent")); - QCOMPARE(block.value("cache_control").toObject(), expectedEphemeral(false)); -} - -void QodeAssistTest::testCacheControlEmptySystemStringIsNotWrapped() -{ - QJsonObject request; - request["system"] = ""; - - Providers::ClaudeCacheControl::apply(request, false); - - QVERIFY(request.value("system").isString()); -} - -void QodeAssistTest::testCacheControlSystemAsArrayMarksLastBlock() -{ - QJsonObject request; - request["system"] = QJsonArray{ - QJsonObject{{"type", "text"}, {"text", "a"}}, QJsonObject{{"type", "text"}, {"text", "b"}}}; - - Providers::ClaudeCacheControl::apply(request, false); - - const QJsonArray system = request.value("system").toArray(); - QCOMPARE(system.size(), 2); - QVERIFY(!system[0].toObject().contains("cache_control")); - QCOMPARE(system[1].toObject().value("cache_control").toObject(), expectedEphemeral(false)); -} - -void QodeAssistTest::testCacheControlToolsLastEntryGetsCacheControl() -{ - QJsonObject request; - request["tools"] = QJsonArray{ - QJsonObject{{"name", "read_file"}}, - QJsonObject{{"name", "edit_file"}}, - QJsonObject{{"name", "search"}}}; - - Providers::ClaudeCacheControl::apply(request, true); - - const QJsonArray tools = request.value("tools").toArray(); - QCOMPARE(tools.size(), 3); - QVERIFY(!tools[0].toObject().contains("cache_control")); - QVERIFY(!tools[1].toObject().contains("cache_control")); - QCOMPARE(tools[2].toObject().value("cache_control").toObject(), expectedEphemeral(true)); -} - -void QodeAssistTest::testCacheControlSingleMessageHistorySkipped() -{ - QJsonObject request; - request["messages"] = QJsonArray{QJsonObject{{"role", "user"}, {"content", "first message"}}}; - - Providers::ClaudeCacheControl::apply(request, false); - - const QJsonArray messages = request.value("messages").toArray(); - QCOMPARE(messages.size(), 1); - QVERIFY(messages[0].toObject().value("content").isString()); -} - -void QodeAssistTest::testCacheControlHistoryBreakpointOnSecondToLastMessage() -{ - QJsonObject request; - request["messages"] = QJsonArray{ - QJsonObject{{"role", "user"}, {"content", "u1"}}, - QJsonObject{{"role", "assistant"}, {"content", "a1"}}, - QJsonObject{{"role", "user"}, {"content", "u2-current"}}}; - - Providers::ClaudeCacheControl::apply(request, false); - - const QJsonArray messages = request.value("messages").toArray(); - QCOMPARE(messages.size(), 3); - QVERIFY(messages[0].toObject().value("content").isString()); - - const QJsonArray assistantContent = messages[1].toObject().value("content").toArray(); - QCOMPARE(assistantContent.size(), 1); - QCOMPARE(assistantContent.first().toObject().value("text").toString(), QString("a1")); - QCOMPARE( - assistantContent.first().toObject().value("cache_control").toObject(), - expectedEphemeral(false)); - - QVERIFY(messages[2].toObject().value("content").isString()); -} - -void QodeAssistTest::testCacheControlHistoryArrayContentMarksLastBlock() -{ - QJsonObject request; - request["messages"] = QJsonArray{ - QJsonObject{ - {"role", "user"}, - {"content", - QJsonArray{ - QJsonObject{{"type", "text"}, {"text", "describe this"}}, - QJsonObject{{"type", "image"}}}}}, - QJsonObject{{"role", "assistant"}, {"content", "ok"}}}; - - Providers::ClaudeCacheControl::apply(request, false); - - const QJsonArray messages = request.value("messages").toArray(); - const QJsonArray content = messages[0].toObject().value("content").toArray(); - QCOMPARE(content.size(), 2); - QVERIFY(!content[0].toObject().contains("cache_control")); - QCOMPARE(content[1].toObject().value("cache_control").toObject(), expectedEphemeral(false)); -} - -void QodeAssistTest::testCacheControlNoSystemNoToolsNoMessagesIsNoop() -{ - QJsonObject request; - request["model"] = "claude-sonnet-4-5"; - request["max_tokens"] = 1024; - - Providers::ClaudeCacheControl::apply(request, false); - - QCOMPARE(request.value("model").toString(), QString("claude-sonnet-4-5")); - QCOMPARE(request.value("max_tokens").toInt(), 1024); - QVERIFY(!request.contains("system")); - QVERIFY(!request.contains("tools")); - QVERIFY(!request.contains("messages")); -} - -void QodeAssistTest::testCacheControlEmptyToolsArrayIsNoop() -{ - QJsonObject request; - request["tools"] = QJsonArray{}; - - Providers::ClaudeCacheControl::apply(request, false); - - QVERIFY(request.value("tools").isArray()); - QVERIFY(request.value("tools").toArray().isEmpty()); -} - -void QodeAssistTest::testGetLineText() -{ - auto reader = createReader("Line 0\nLine 1\nLine 2"); - - QCOMPARE(reader.getLineText(0), QString("Line 0")); - QCOMPARE(reader.getLineText(1), QString("Line 1")); - QCOMPARE(reader.getLineText(2), QString("Line 2")); - QCOMPARE(reader.getLineText(0, 4), QString("Line")); -} - -void QodeAssistTest::testGetContext() -{ - auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - QCOMPARE(reader.getContextBefore(0, -1, 2), QString("Line 0")); - QCOMPARE(reader.getContextAfter(0, -1, 2), QString("Line 0\nLine 1")); - - QCOMPARE(reader.getContextBefore(1, -1, 2), QString("Line 0\nLine 1")); - QCOMPARE(reader.getContextAfter(1, -1, 2), QString("Line 1\nLine 2")); - - QCOMPARE(reader.getContextBefore(2, -1, 2), QString("Line 1\nLine 2")); - QCOMPARE(reader.getContextAfter(2, -1, 2), QString("Line 2\nLine 3")); - - QCOMPARE(reader.getContextBefore(3, -1, 2), QString("Line 2\nLine 3")); - QCOMPARE(reader.getContextAfter(3, -1, 2), QString("Line 3\nLine 4")); - - QCOMPARE(reader.getContextBefore(0, 1, 2), QString("L")); - QCOMPARE(reader.getContextAfter(0, 1, 2), QString("ine 0\nLine 1")); - - QCOMPARE(reader.getContextBefore(1, 1, 2), QString("Line 0\nL")); - QCOMPARE(reader.getContextAfter(1, 1, 2), QString("ine 1\nLine 2")); - - QCOMPARE(reader.getContextBefore(2, 1, 2), QString("Line 1\nL")); - QCOMPARE(reader.getContextAfter(2, 1, 2), QString("ine 2\nLine 3")); - - QCOMPARE(reader.getContextBefore(3, 1, 2), QString("Line 2\nL")); - QCOMPARE(reader.getContextAfter(3, 1, 2), QString("ine 3\nLine 4")); -} - -void QodeAssistTest::testGetContextWithCopyright() -{ - auto reader = createReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3"); - - QCOMPARE(reader.getContextBefore(0, -1, 2), QString()); - QCOMPARE(reader.getContextAfter(0, -1, 2), QString("Line 0")); - - QCOMPARE(reader.getContextBefore(1, -1, 2), QString("Line 0")); - QCOMPARE(reader.getContextAfter(1, -1, 2), QString("Line 0\nLine 1")); - - QCOMPARE(reader.getContextBefore(2, -1, 2), QString("Line 0\nLine 1")); - QCOMPARE(reader.getContextAfter(2, -1, 2), QString("Line 1\nLine 2")); - - QCOMPARE(reader.getContextBefore(3, -1, 2), QString("Line 1\nLine 2")); - QCOMPARE(reader.getContextAfter(3, -1, 2), QString("Line 2\nLine 3")); - - QCOMPARE(reader.getContextBefore(0, 1, 2), QString()); - QCOMPARE(reader.getContextAfter(0, 1, 2), QString("Line 0")); - - QCOMPARE(reader.getContextBefore(1, 1, 2), QString("L")); - QCOMPARE(reader.getContextAfter(1, 1, 2), QString("ine 0\nLine 1")); - - QCOMPARE(reader.getContextBefore(2, 1, 2), QString("Line 0\nL")); - QCOMPARE(reader.getContextAfter(2, 1, 2), QString("ine 1\nLine 2")); - - QCOMPARE(reader.getContextBefore(3, 1, 2), QString("Line 1\nL")); - QCOMPARE(reader.getContextAfter(3, 1, 2), QString("ine 2\nLine 3")); -} - -void QodeAssistTest::testReadWholeFile() -{ - auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - QCOMPARE(reader.readWholeFileBefore(0, -1), QString("Line 0")); - QCOMPARE(reader.readWholeFileAfter(0, -1), QString("Line 0\nLine 1\nLine 2\nLine 3\nLine 4")); - - QCOMPARE(reader.readWholeFileBefore(1, -1), QString("Line 0\nLine 1")); - QCOMPARE(reader.readWholeFileAfter(1, -1), QString("Line 1\nLine 2\nLine 3\nLine 4")); - - QCOMPARE(reader.readWholeFileBefore(2, -1), QString("Line 0\nLine 1\nLine 2")); - QCOMPARE(reader.readWholeFileAfter(2, -1), QString("Line 2\nLine 3\nLine 4")); - - QCOMPARE(reader.readWholeFileBefore(3, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - QCOMPARE(reader.readWholeFileAfter(3, -1), QString("Line 3\nLine 4")); - - QCOMPARE(reader.readWholeFileBefore(4, -1), QString("Line 0\nLine 1\nLine 2\nLine 3\nLine 4")); - QCOMPARE(reader.readWholeFileAfter(4, -1), QString("Line 4")); - - QCOMPARE(reader.readWholeFileBefore(0, 1), QString("L")); - QCOMPARE(reader.readWholeFileAfter(0, 1), QString("ine 0\nLine 1\nLine 2\nLine 3\nLine 4")); - - QCOMPARE(reader.readWholeFileBefore(1, 1), QString("Line 0\nL")); - QCOMPARE(reader.readWholeFileAfter(1, 1), QString("ine 1\nLine 2\nLine 3\nLine 4")); - - QCOMPARE(reader.readWholeFileBefore(2, 1), QString("Line 0\nLine 1\nL")); - QCOMPARE(reader.readWholeFileAfter(2, 1), QString("ine 2\nLine 3\nLine 4")); - - QCOMPARE(reader.readWholeFileBefore(3, 1), QString("Line 0\nLine 1\nLine 2\nL")); - QCOMPARE(reader.readWholeFileAfter(3, 1), QString("ine 3\nLine 4")); - - QCOMPARE(reader.readWholeFileBefore(4, 1), QString("Line 0\nLine 1\nLine 2\nLine 3\nL")); - QCOMPARE(reader.readWholeFileAfter(4, 1), QString("ine 4")); -} - -void QodeAssistTest::testReadWholeFileWithCopyright() -{ - auto reader = createReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3"); - - QCOMPARE(reader.readWholeFileBefore(0, -1), QString()); - QCOMPARE(reader.readWholeFileAfter(0, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(1, -1), QString("Line 0")); - QCOMPARE(reader.readWholeFileAfter(1, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(2, -1), QString("Line 0\nLine 1")); - QCOMPARE(reader.readWholeFileAfter(2, -1), QString("Line 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(3, -1), QString("Line 0\nLine 1\nLine 2")); - QCOMPARE(reader.readWholeFileAfter(3, -1), QString("Line 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(0, 0), QString()); - QCOMPARE(reader.readWholeFileAfter(0, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(1, 0), QString()); - QCOMPARE(reader.readWholeFileAfter(1, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(2, 0), QString("Line 0\n")); - QCOMPARE(reader.readWholeFileAfter(2, 0), QString("Line 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(3, 0), QString("Line 0\nLine 1\n")); - QCOMPARE(reader.readWholeFileAfter(3, 0), QString("Line 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(0, 1), QString()); - QCOMPARE(reader.readWholeFileAfter(0, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(1, 1), QString("L")); - QCOMPARE(reader.readWholeFileAfter(1, 1), QString("ine 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(2, 1), QString("Line 0\nL")); - QCOMPARE(reader.readWholeFileAfter(2, 1), QString("ine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(3, 1), QString("Line 0\nLine 1\nL")); - QCOMPARE(reader.readWholeFileAfter(3, 1), QString("ine 2\nLine 3")); -} - -void QodeAssistTest::testReadWholeFileWithMultilineCopyright() -{ - auto reader = createReader( - "/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\n" - "Line 0\nLine 1\nLine 2\nLine 3"); - - QCOMPARE(reader.readWholeFileBefore(0, -1), QString()); - QCOMPARE(reader.readWholeFileAfter(0, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(1, -1), QString()); - QCOMPARE(reader.readWholeFileAfter(1, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(2, -1), QString()); - QCOMPARE(reader.readWholeFileAfter(2, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(3, -1), QString()); - QCOMPARE(reader.readWholeFileAfter(3, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(4, -1), QString()); - QCOMPARE(reader.readWholeFileAfter(4, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(5, -1), QString("Line 0")); - QCOMPARE(reader.readWholeFileAfter(5, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(6, -1), QString("Line 0\nLine 1")); - QCOMPARE(reader.readWholeFileAfter(6, -1), QString("Line 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(7, -1), QString("Line 0\nLine 1\nLine 2")); - QCOMPARE(reader.readWholeFileAfter(7, -1), QString("Line 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(8, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - QCOMPARE(reader.readWholeFileAfter(8, -1), QString("Line 3")); - - QCOMPARE(reader.readWholeFileBefore(0, 0), QString()); - QCOMPARE(reader.readWholeFileAfter(0, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(1, 0), QString()); - QCOMPARE(reader.readWholeFileAfter(1, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(2, 0), QString()); - QCOMPARE(reader.readWholeFileAfter(2, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(3, 0), QString()); - QCOMPARE(reader.readWholeFileAfter(3, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(4, 0), QString()); - QCOMPARE(reader.readWholeFileAfter(4, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(5, 0), QString()); - QCOMPARE(reader.readWholeFileAfter(5, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(6, 0), QString("Line 0\n")); - QCOMPARE(reader.readWholeFileAfter(6, 0), QString("Line 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(7, 0), QString("Line 0\nLine 1\n")); - QCOMPARE(reader.readWholeFileAfter(7, 0), QString("Line 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(8, 0), QString("Line 0\nLine 1\nLine 2\n")); - QCOMPARE(reader.readWholeFileAfter(8, 0), QString("Line 3")); - - QCOMPARE(reader.readWholeFileBefore(0, 1), QString()); - QCOMPARE(reader.readWholeFileAfter(0, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(1, 1), QString()); - QCOMPARE(reader.readWholeFileAfter(1, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(2, 1), QString()); - QCOMPARE(reader.readWholeFileAfter(2, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(3, 1), QString()); - QCOMPARE(reader.readWholeFileAfter(3, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(4, 1), QString()); - QCOMPARE(reader.readWholeFileAfter(4, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(5, 1), QString("L")); - QCOMPARE(reader.readWholeFileAfter(5, 1), QString("ine 0\nLine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(6, 1), QString("Line 0\nL")); - QCOMPARE(reader.readWholeFileAfter(6, 1), QString("ine 1\nLine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(7, 1), QString("Line 0\nLine 1\nL")); - QCOMPARE(reader.readWholeFileAfter(7, 1), QString("ine 2\nLine 3")); - - QCOMPARE(reader.readWholeFileBefore(8, 1), QString("Line 0\nLine 1\nLine 2\nL")); - QCOMPARE(reader.readWholeFileAfter(8, 1), QString("ine 3")); -} - -void QodeAssistTest::testFindCopyrightSingleLine() -{ - auto reader = createReader("/* Copyright (C) 2024 */\nCode line 0\nCode line 1"); - - const auto info = reader.findCopyright(); - QVERIFY(info.found); - QCOMPARE(info.startLine, 0); - QCOMPARE(info.endLine, 0); -} - -void QodeAssistTest::testFindCopyrightMultiLine() -{ - auto reader = createReader( - "/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\nCode line 0"); - - const auto info = reader.findCopyright(); - QVERIFY(info.found); - QCOMPARE(info.startLine, 0); - QCOMPARE(info.endLine, 4); -} - -void QodeAssistTest::testFindCopyrightMultipleBlocks() -{ - auto reader = createReader("/* Copyright 2023 */\n\n/* Copyright 2024 */\nCode"); - - const auto info = reader.findCopyright(); - QVERIFY(info.found); - QCOMPARE(info.startLine, 0); - QCOMPARE(info.endLine, 0); -} - -void QodeAssistTest::testFindCopyrightNoCopyright() -{ - auto reader = createReader("/* Just a comment */\nCode line 0"); - - const auto info = reader.findCopyright(); - QVERIFY(!info.found); - QCOMPARE(info.startLine, -1); - QCOMPARE(info.endLine, -1); -} - -void QodeAssistTest::testGetContextBetween() -{ - auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - QCOMPARE(reader.getContextBetween(2, -1, 0, -1), QString()); - QCOMPARE(reader.getContextBetween(0, -1, 0, -1), QString("Line 0")); - QCOMPARE(reader.getContextBetween(1, -1, 1, -1), QString("Line 1")); - QCOMPARE(reader.getContextBetween(1, 3, 1, 1), QString()); - QCOMPARE(reader.getContextBetween(1, 3, 1, 3), QString()); - QCOMPARE(reader.getContextBetween(1, 3, 1, 4), QString("e")); - - QCOMPARE(reader.getContextBetween(1, -1, 3, -1), QString("Line 1\nLine 2\nLine 3")); - QCOMPARE(reader.getContextBetween(1, 2, 3, -1), QString("ne 1\nLine 2\nLine 3")); - QCOMPARE(reader.getContextBetween(1, -1, 3, 2), QString("Line 1\nLine 2\nLi")); - QCOMPARE(reader.getContextBetween(1, 2, 3, 2), QString("ne 1\nLine 2\nLi")); -} - -void QodeAssistTest::testPrepareContext() -{ - auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - QCOMPARE( - reader.prepareContext(2, 3, *createSettingsForWholeFile()), - (LLMCore::ContextData{ - .prefix = "Line 0\nLine 1\nLin", - .suffix = "e 2\nLine 3\nLine 4", - .fileContext = kTestFileContext})); - - QCOMPARE( - reader.prepareContext(2, 3, *createSettingsForLines(1, 1)), - (LLMCore::ContextData{ - .prefix = "Line 1\nLin", .suffix = "e 2\nLine 3", .fileContext = kTestFileContext})); - - QCOMPARE( - reader.prepareContext(2, 3, *createSettingsForLines(2, 2)), - (LLMCore::ContextData{ - .prefix = "Line 0\nLine 1\nLin", - .suffix = "e 2\nLine 3\nLine 4", - .fileContext = kTestFileContext})); -} - -void QodeAssistTest::testHistorySnapshotUsesCurrentVersion() -{ - const QJsonObject root = Session::HistorySerializer::toJson(sampleHistory()); - - QCOMPARE(root.value("version").toString(), Session::HistorySerializer::currentVersion()); - QCOMPARE(root.value("messages").toArray().size(), 2); -} - -void QodeAssistTest::testHistorySnapshotRoundTrip() -{ - const Session::ConversationHistory history = sampleHistory(); - - const auto restored = Session::HistorySerializer::fromJson( - Session::HistorySerializer::toJson(history)); - - QVERIFY(restored.has_value()); - QCOMPARE(restored->size(), history.size()); - QCOMPARE(restored->at(1).blocks.size(), history.at(1).blocks.size()); - QCOMPARE(*restored, history); -} - -void QodeAssistTest::testUnsupportedChatVersionIsRejected() -{ - QJsonObject root; - root["version"] = "9.9"; - root["messages"] = QJsonArray{}; - - QVERIFY(!Session::HistorySerializer::fromJson(root).has_value()); - QVERIFY(!Session::HistorySerializer::isSupportedVersion("9.9")); - QVERIFY(Session::HistorySerializer::isSupportedVersion("0.1")); - QVERIFY(Session::HistorySerializer::isSupportedVersion("0.2")); -} - -void QodeAssistTest::testChatFileWithoutMessagesArrayIsRejected() -{ - QJsonObject missing; - missing["version"] = Session::HistorySerializer::currentVersion(); - QVERIFY(!Session::HistorySerializer::fromJson(missing).has_value()); - - QJsonObject wrongType; - wrongType["version"] = Session::HistorySerializer::currentVersion(); - wrongType["messages"] = "not an array"; - QVERIFY(!Session::HistorySerializer::fromJson(wrongType).has_value()); - - QJsonObject empty; - empty["version"] = Session::HistorySerializer::currentVersion(); - empty["messages"] = QJsonArray{}; - const auto history = Session::HistorySerializer::fromJson(empty); - QVERIFY(history.has_value()); - QCOMPARE(history->size(), 0); -} - -void QodeAssistTest::testLegacyChatFileConvertsToHistory() -{ - QJsonObject root; - root["version"] = "0.2"; - root["messages"] = QJsonArray{ - QJsonObject{{"role", 1}, {"content", "explain this"}, {"id", "u1"}}, - QJsonObject{ - {"role", 5}, - {"content", "weighing options\n[Signature: sig-1...]"}, - {"id", "r1"}, - {"signature", "sig-1"}, - {"usage", QJsonObject{{"promptTokens", 120}, {"completionTokens", 40}}}}, - QJsonObject{ - {"role", 3}, - {"content", "read_file\nint main() {}"}, - {"id", "t1"}, - {"toolName", "read_file"}, - {"toolResult", "int main() {}"}}, - QJsonObject{{"role", 2}, {"content", "here is the answer"}, {"id", "r1"}}}; - - const auto history = Session::HistorySerializer::fromJson(root); - - QVERIFY(history.has_value()); - QCOMPARE(history->size(), 2); - - const Session::Message &user = history->at(0); - QCOMPARE(user.role, Session::MessageRole::User); - QCOMPARE(user.blocks.size(), 1); - QCOMPARE(std::get(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 QodeAssistTest::testLegacyToolRowWithoutMetadataKeepsItsText() -{ - QJsonObject root; - root["version"] = "0.2"; - root["messages"] = QJsonArray{ - QJsonObject{{"role", 1}, {"content", "list the files"}, {"id", "u1"}}, - QJsonObject{{"role", 3}, {"content", "main.cpp\nmain.qml"}, {"id", "t1"}}}; - - const auto history = Session::HistorySerializer::fromJson(root); - QVERIFY(history.has_value()); - - const QList rows = Session::projectToRows(*history); - QCOMPARE(rows.size(), 2); - QCOMPARE(rows.at(1).kind, Session::RowKind::Tool); - QCOMPARE(rows.at(1).id, QString("t1")); - QCOMPARE(rows.at(1).content, QString("main.cpp\nmain.qml")); -} - -void QodeAssistTest::testChatRowsRoundTripThroughHistory() -{ - Session::MessageRow user; - user.kind = Session::RowKind::User; - user.id = "u1"; - user.content = "explain this"; - user.attachments = {Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}}; - user.images = {Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}; - - Session::MessageRow thinking; - thinking.kind = Session::RowKind::Thinking; - thinking.id = "r1"; - thinking.content = "weighing options\n[Signature: sig-1...]"; - thinking.signature = "sig-1"; - thinking.usage = Session::Usage{120, 40, 80, 12}; - - Session::MessageRow tool; - tool.kind = Session::RowKind::Tool; - tool.id = "t1"; - tool.content = "read_file\nint main() {}"; - tool.toolName = "read_file"; - tool.toolArguments = QJsonObject{{"path", "main.cpp"}}; - tool.toolResult = "int main() {}"; - - Session::MessageRow assistant; - assistant.kind = Session::RowKind::Assistant; - assistant.id = "r1"; - assistant.content = "here is the answer"; - - Session::MessageRow fileEdit; - fileEdit.kind = Session::RowKind::FileEdit; - fileEdit.id = "e1"; - fileEdit.content = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\"}"; - - const QList 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 QodeAssistTest::testCompressedChatShapeReloadsAsOneAssistantRow() -{ - Session::Message summary; - summary.role = Session::MessageRole::Assistant; - summary.id = "c1"; - summary.blocks = {Session::TextBlock{"# Chat Summary\n\nwe discussed the parser"}}; - - Session::ConversationHistory compressed; - compressed.append(summary); - - const auto reloaded = Session::HistorySerializer::fromJson( - Session::HistorySerializer::toJson(compressed)); - QVERIFY(reloaded.has_value()); - - const QList rows = Session::projectToRows(*reloaded); - QCOMPARE(rows.size(), 1); - QCOMPARE(rows.at(0).kind, Session::RowKind::Assistant); - QCOMPARE(rows.at(0).content, QString("# Chat Summary\n\nwe discussed the parser")); -} - -void QodeAssistTest::testHistoryAppliesToChatModel() -{ - Chat::ChatModel model; - Session::Session session; - attachModelToSession(session, model); - - session.setHistory(historyWithoutFileEdits()); - - QCOMPARE(model.rowCount(), 4); - - QCOMPARE( - model.data(model.index(0), Chat::ChatModel::RoleType).value(), - Chat::ChatModel::ChatRole::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 QodeAssistTest::testAdjacentThinkingBlocksSurviveReload() -{ - Session::Message assistant; - assistant.role = Session::MessageRole::Assistant; - assistant.id = "r1"; - assistant.blocks - = {Session::ThinkingBlock{"first", "sig-a", true}, - Session::ThinkingBlock{"second", "sig-b", true}, - Session::TextBlock{"the answer"}}; - - Session::ConversationHistory history; - history.append(assistant); - - const QList rows = Session::projectToRows(history); - - QCOMPARE(rows.size(), 3); - QCOMPARE(rows.at(0).content, QString("first\n[Signature: sig-a...]")); - QCOMPARE(rows.at(1).content, QString("second\n[Signature: sig-b...]")); - QCOMPARE(Session::buildFromRows(rows), history); -} - -void QodeAssistTest::testSessionAppendsUserTurnBeforeSending() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn( - {Session::TextBlock{"explain this"}, Session::AttachmentBlock{"notes.txt", "notes_ab.txt"}}, - std::nullopt); - - QCOMPARE(backend.turnCount(), 1); - QCOMPARE(backend.historySizeAtSend(), qsizetype(1)); - QCOMPARE(backend.lastUserBlocks().size(), 2); - QVERIFY(!backend.lastContext().has_value()); - - QCOMPARE(session.history().size(), 1); - QCOMPARE(session.history().at(0).role, Session::MessageRole::User); - QCOMPARE(session.rows().size(), 1); - QCOMPARE(session.rows().at(0).content, QString("explain this")); - QCOMPARE(session.rows().at(0).attachments.size(), 1); -} - -void QodeAssistTest::testSessionStreamsTextThinkingAndTools() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt); - backend.script(scriptedTurn("r1")); - - QCOMPARE(session.history().size(), 2); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.role, Session::MessageRole::Assistant); - QCOMPARE(assistant.id, QString("r1")); - QCOMPARE(assistant.usage, (Session::Usage{120, 40, 80, 12})); - QCOMPARE(assistant.blocks.size(), 4); - QCOMPARE( - assistant.blocks.at(0), - (Session::ContentBlock{Session::ThinkingBlock{"weighing options", "sig-1", false}})); - QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"let me check"}})); - QCOMPARE( - assistant.blocks.at(2), - (Session::ContentBlock{Session::ToolCallBlock{ - "t1", - "read_file", - QJsonObject{{"path", "main.cpp"}}, - "int main() {}", - {}, - "completed"}})); - QCOMPARE( - assistant.blocks.at(3), (Session::ContentBlock{Session::TextBlock{"here is the answer"}})); -} - -void QodeAssistTest::testSessionTrimsStreamedText() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::TextDelta{"r1", "\n\n"}, - Session::TextDelta{"r1", " here is"}, - Session::TextDelta{"r1", " the answer \n"}}); - - QCOMPARE(session.history().at(1).blocks.size(), 1); - QCOMPARE( - session.history().at(1).blocks.at(0), - (Session::ContentBlock{Session::TextBlock{"here is the answer"}})); -} - -void QodeAssistTest::testSessionAbandoningTheTurnCancelsTheBackend() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); - - const int cancelsBeforeTruncate = backend.cancelCount(); - session.truncateRows(0); - QCOMPARE(backend.cancelCount(), cancelsBeforeTruncate + 1); - - const int cancelsBeforeLoad = backend.cancelCount(); - session.setHistory(Session::ConversationHistory{}); - QCOMPARE(backend.cancelCount(), cancelsBeforeLoad + 1); -} - -void QodeAssistTest::testSwitchingBackendsCancelsTheActiveTurn() -{ - FakeChatBackend first; - FakeChatBackend second; - Session::Session session; - session.setBackend(&first); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - first.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); - - const int cancelsBeforeSwap = first.cancelCount(); - session.setBackend(&second); - QCOMPARE(first.cancelCount(), cancelsBeforeSwap + 1); - - const qsizetype rowsAfterSwap = session.rows().size(); - first.script({Session::TextDelta{"r1", " stale"}}); - QCOMPARE(session.rows().size(), rowsAfterSwap); - QCOMPARE(session.rows().at(1).content, QString("partial")); -} - -void QodeAssistTest::testSessionForwardsTitleFromTheEventStream() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - QStringList titles; - connect( - &session, - &Session::Session::sessionInfoReceived, - this, - [&titles](const QString &title) { titles.append(title); }); - - session.sendTurn({Session::TextBlock{"hi"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::TextDelta{"r1", "hello"}, - Session::SessionInfo{.title = "Working title"}, - Session::TurnCompleted{"r1"}, - Session::SessionInfo{.title = "Chat about parsers"}}); - - QCOMPARE(titles, (QStringList{"Working title", "Chat about parsers"})); - - backend.script({Session::SessionInfo{.title = QString()}}); - QCOMPARE(titles.size(), 2); -} - -void QodeAssistTest::testSessionProjectionMatchesHistory() -{ - Chat::ChatModel model; - FakeChatBackend backend; - Session::Session session; - attachModelToSession(session, model); - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt); - backend.script(scriptedTurn("r1")); - - const QList expected = Session::projectToRows(session.history()); - QCOMPARE(session.rows(), expected); - QCOMPARE(model.rowCount(), int(expected.size())); - - for (int row = 0; row < expected.size(); ++row) { - QCOMPARE( - model.data(model.index(row), Chat::ChatModel::Content).toString(), - expected.at(row).content); - } - - QCOMPARE(model.sessionPromptTokens(), 120); - QCOMPARE(model.sessionCompletionTokens(), 40); -} - -void QodeAssistTest::testSessionAccumulatesStreamedThinking() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::ThinkingReceived{"r1", "step", QString(), false}, - Session::ThinkingReceived{"r1", "step one", QString(), false}, - Session::ThinkingReceived{"r1", "step one and two", QString(), false}, - Session::TextDelta{"r1", "done"}}); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.blocks.size(), 2); - QCOMPARE( - assistant.blocks.at(0), - (Session::ContentBlock{Session::ThinkingBlock{"step one and two", QString(), false}})); - QCOMPARE(session.rows().size(), 3); -} - -void QodeAssistTest::testSessionKeepsThinkingAfterToolContinuation() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::ThinkingReceived{"r1", "first thought", "sig-a", false}, - llmToolStarted("r1", "t1", "read_file"), - llmToolCompleted("r1", "t1", "read_file", "contents"), - Session::ThinkingReceived{"r1", "second thought", "sig-b", false}, - Session::TextDelta{"r1", "done"}}); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.blocks.size(), 4); - QCOMPARE( - assistant.blocks.at(0), - (Session::ContentBlock{Session::ThinkingBlock{"first thought", "sig-a", false}})); - QCOMPARE( - assistant.blocks.at(2), - (Session::ContentBlock{Session::ThinkingBlock{"second thought", "sig-b", false}})); -} - -void QodeAssistTest::testSessionDropsPreToolTextWhenAsked() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::TextDelta{"r1", "leaked tool preamble"}, - llmToolStarted("r1", "t1", "read_file", {}, true), - llmToolCompleted("r1", "t1", "read_file", "contents"), - Session::TextDelta{"r1", "the answer"}}); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.blocks.size(), 2); - QCOMPARE( - assistant.blocks.at(0), - (Session::ContentBlock{Session::ToolCallBlock{ - "t1", "read_file", QJsonObject{}, "contents", {}, "completed"}})); - QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"the answer"}})); - QCOMPARE(session.rows(), Session::projectToRows(session.history())); -} - -void QodeAssistTest::testSessionToolResultAppendsFileEditRow() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - const QString result - = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\",\"status\":\"pending\"}"; - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - llmToolStarted("r1", "t1", "edit_file"), - llmToolCompleted("r1", "t1", "edit_file", result)}); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.blocks.size(), 2); - QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::FileEditBlock{"e1", result}})); - - QVERIFY(session.updateFileEditStatus("e1", "applied", "Successfully applied")); - - const Session::MessageRow &editRow = session.rows().last(); - QCOMPARE(editRow.kind, Session::RowKind::FileEdit); - QVERIFY(editRow.content.contains("\"status\":\"applied\"")); - QVERIFY(editRow.content.contains("\"status_message\":\"Successfully applied\"")); -} - -void QodeAssistTest::testSessionRepeatedToolResultKeepsOneFileEdit() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - const QString result - = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\",\"status\":\"pending\"}"; - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - llmToolStarted("r1", "t1", "edit_file"), - llmToolCompleted("r1", "t1", "edit_file", result), - llmToolCompleted("r1", "t1", "edit_file", result)}); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.blocks.size(), 2); - - qsizetype editBlocks = 0; - for (const Session::ContentBlock &block : assistant.blocks) { - if (std::holds_alternative(block)) - ++editBlocks; - } - QCOMPARE(editBlocks, qsizetype(1)); - QCOMPARE(session.rows(), Session::projectToRows(session.history())); -} - -void QodeAssistTest::testRunningToolStatusInterruptsOnTurnEnd() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, llmToolStarted("r1", "t1", "read_file")}); - QCOMPARE(session.rows().at(1).toolStatus, QString("in_progress")); - - session.cancel(); - - QCOMPARE(session.rows().at(1).toolStatus, QString("interrupted")); - - const auto *tool - = std::get_if(&session.history().at(1).blocks.at(0)); - QVERIFY(tool); - QCOMPARE(tool->status, QString("interrupted")); -} - -void QodeAssistTest::testRunningToolStatusInterruptsOnLoad() -{ - Session::Message assistant; - assistant.role = Session::MessageRole::Assistant; - assistant.id = "r1"; - assistant.blocks - = {Session::ToolCallBlock{ - "call-1", "Edit main.cpp", QJsonObject{}, {}, "edit", "in_progress", {}, true}}; - - Session::ConversationHistory history; - history.append(assistant); - - const auto reloaded - = Session::HistorySerializer::fromJson(Session::HistorySerializer::toJson(history)); - QVERIFY(reloaded.has_value()); - - const auto *tool = std::get_if(&reloaded->at(0).blocks.at(0)); - QVERIFY(tool); - QCOMPARE(tool->status, QString("interrupted")); - - const auto rebuilt = Session::buildFromRows(Session::projectToRows(history)); - const auto *rowTool = std::get_if(&rebuilt.at(0).blocks.at(0)); - QVERIFY(rowTool); - QCOMPARE(rowTool->status, QString("interrupted")); - - QCOMPARE(Session::restoredToolStatus("completed"), QString("completed")); - QCOMPARE(Session::restoredToolStatus("failed"), QString("failed")); - QCOMPARE(Session::restoredToolStatus(QString()), QString()); -} - -void QodeAssistTest::testAgentDiffBecomesAppliedFileEdit() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - struct Recorded - { - QString turnId; - QString editId; - QString filePath; - QString oldContent; - QString newContent; - }; - QList recorded; - connect( - &session, - &Session::Session::agentFileEditRecorded, - this, - [&recorded]( - const QString &turnId, - const QString &editId, - const QString &filePath, - const QString &oldContent, - const QString &newContent) { - recorded.append({turnId, editId, filePath, oldContent, newContent}); - }); - - const QJsonObject details{ - {"diffs", - QJsonArray{QJsonObject{ - {"path", "/proj/main.cpp"}, {"oldText", "int a;"}, {"newText", "int b;"}}}}}; - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Edit main.cpp", - .kind = "edit", - .status = "in_progress", - .fromAgent = true}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Edit main.cpp", - .kind = "edit", - .status = "completed", - .details = details, - .fromAgent = true}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Edit main.cpp", - .kind = "edit", - .status = "completed", - .details = details, - .fromAgent = true}}); - - QCOMPARE(recorded.size(), 1); - QCOMPARE(recorded.at(0).turnId, QString("r1")); - QCOMPARE(recorded.at(0).editId, QString("agent-call-1-0")); - QCOMPARE(recorded.at(0).filePath, QString("/proj/main.cpp")); - QCOMPARE(recorded.at(0).oldContent, QString("int a;")); - QCOMPARE(recorded.at(0).newContent, QString("int b;")); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.blocks.size(), 2); - - const auto *edit = std::get_if(&assistant.blocks.at(1)); - QVERIFY(edit); - QCOMPARE(edit->id, QString("agent-call-1-0")); - - const auto payload = Session::parseFileEditPayload(edit->payload); - QVERIFY(payload.has_value()); - QCOMPARE(payload->value("status").toString(), QString("applied")); - QCOMPARE(payload->value("file").toString(), QString("/proj/main.cpp")); - QCOMPARE(payload->value("old_content").toString(), QString("int a;")); - QCOMPARE(payload->value("new_content").toString(), QString("int b;")); - - const Session::MessageRow &editRow = session.rows().last(); - QCOMPARE(editRow.kind, Session::RowKind::FileEdit); - QVERIFY(editRow.content.contains("\"status\":\"applied\"")); - QCOMPARE(session.rows(), Session::projectToRows(session.history())); -} - -void QodeAssistTest::testAgentDiffSkipsUnrevertableRegistrations() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - int recorded = 0; - connect( - &session, - &Session::Session::agentFileEditRecorded, - this, - [&recorded]( - const QString &, const QString &, const QString &, const QString &, const QString &) { - ++recorded; - }); - - const QJsonObject details{ - {"diffs", - QJsonArray{ - QJsonObject{{"path", "/proj/deleted.cpp"}, {"oldText", "gone"}, {"newText", ""}}, - QJsonObject{ - {"path", "/proj/big.cpp"}, - {"oldText", "int a;"}, - {"newText", "int b;"}, - {"truncated", true}}}}}; - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Edit files", - .kind = "edit", - .status = "completed", - .details = details, - .fromAgent = true}}); - - QCOMPARE(recorded, 0); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.blocks.size(), 3); - QVERIFY(std::get_if(&assistant.blocks.at(1))); - QVERIFY(std::get_if(&assistant.blocks.at(2))); - QCOMPARE(session.rows(), Session::projectToRows(session.history())); -} - -void QodeAssistTest::testAgentFileEditRevertRoundTrip() -{ - QTemporaryDir dir; - QVERIFY(dir.isValid()); - - const QString filePath = dir.filePath("edited.cpp"); - const QString oldContent = "int a;\n"; - const QString newContent = "int b;\n"; - - { - QFile file(filePath); - QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); - file.write(newContent.toUtf8()); - } - - const QString editId - = "agent-test-" + QUuid::createUuid().toString(QUuid::WithoutBraces); - const QString requestId - = "req-" + QUuid::createUuid().toString(QUuid::WithoutBraces); - - auto &changes = Context::FileEditManager::instance(); - changes.registerAppliedFileEdit(editId, filePath, oldContent, newContent, requestId); - - const auto registered = changes.getFileEdit(editId); - QCOMPARE(registered.status, Context::FileEditManager::Applied); - QCOMPARE(registered.filePath, filePath); - - QVERIFY(changes.applyPendingEditsForRequest(requestId)); - - QSignalSpy undoneSpy(&changes, &Context::FileEditManager::fileEditUndone); - QVERIFY(changes.undoFileEdit(editId)); - QCOMPARE(undoneSpy.count(), 1); - QCOMPARE(undoneSpy.at(0).at(0).toString(), editId); - - QFile file(filePath); - QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); - QCOMPARE(QString::fromUtf8(file.readAll()), oldContent); - - QCOMPARE(changes.getFileEdit(editId).status, Context::FileEditManager::Rejected); -} - -void QodeAssistTest::testSessionIgnoresEchoedFileEditMarker() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - const QString echoed - = "Pre-compression history (/src): 1 matching message(s)\n\n[#3 assistant]\n" - "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\"}"; - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - llmToolStarted("r1", "t1", "read_original_history"), - llmToolCompleted("r1", "t1", "read_original_history", echoed)}); - - QCOMPARE(session.history().at(1).blocks.size(), 1); - QCOMPARE(session.rows().size(), 2); -} - -void QodeAssistTest::testSessionTruncationKeepsBlockOrder() -{ - Session::Message user; - user.role = Session::MessageRole::User; - user.blocks - = {Session::TextBlock{"look at this"}, - Session::AttachmentBlock{"notes.txt", "notes_ab.txt"}, - Session::ImageBlock{"shot.png", "shot_cd.png", "image/png"}}; - - Session::Message assistant; - assistant.role = Session::MessageRole::Assistant; - assistant.id = "r1"; - assistant.blocks = {Session::TextBlock{"sure"}}; - - Session::ConversationHistory history; - history.append(user); - history.append(assistant); - - Session::Session session; - session.setHistory(history); - session.truncateRows(1); - - QCOMPARE(session.history().size(), 1); - QCOMPARE(session.history().at(0).blocks, user.blocks); - QCOMPARE(session.rows(), Session::projectToRows(session.history())); -} - -void QodeAssistTest::testHistorySnapshotReportsUnreadableBlocks() -{ - QJsonObject block; - block["type"] = "from_a_newer_build"; - - QJsonObject message; - message["role"] = "assistant"; - message["blocks"] = QJsonArray{block, QJsonObject{{"type", "text"}, {"text", "kept"}}}; - - QJsonObject root; - root["version"] = Session::HistorySerializer::currentVersion(); - root["messages"] = QJsonArray{message}; - - int dropped = -1; - const auto history = Session::HistorySerializer::fromJson(root, &dropped); - - QVERIFY(history.has_value()); - QCOMPARE(dropped, 1); - QCOMPARE(history->at(0).blocks.size(), 1); -} - -void QodeAssistTest::testSessionUsageLandsOnTheTurn() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::ThinkingReceived{"r1", "hmm", "sig", false}, - Session::TextDelta{"r1", "done"}, - Session::UsageReported{"r1", Session::Usage{10, 5, 2, 1}}, - Session::TurnCompleted{"r1"}}); - - QCOMPARE(session.history().at(1).usage, (Session::Usage{10, 5, 2, 1})); - QCOMPARE(session.rows().at(1).usage, (Session::Usage{10, 5, 2, 1})); - QCOMPARE(session.rows().at(2).usage, Session::Usage{}); -} - -void QodeAssistTest::testSessionIgnoresEventsFromOtherTurns() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::TextDelta{"r1", "mine"}, - Session::TextDelta{"other", "not mine"}, - llmToolStarted("other", "t9", "read_file")}); - - QCOMPARE(session.history().size(), 2); - QCOMPARE(session.history().at(1).blocks.size(), 1); - QCOMPARE( - session.history().at(1).blocks.at(0), (Session::ContentBlock{Session::TextBlock{"mine"}})); -} - -void QodeAssistTest::testSessionReportsFailureWithoutStartedTurn() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - QString reported; - QObject::connect( - &session, &Session::Session::turnFailed, &session, [&reported](const QString &error) { - reported = error; - }); - - backend.script({Session::TurnFailed{QString(), "no provider configured"}}); - - QCOMPARE(reported, QString("no provider configured")); -} - -void QodeAssistTest::testSessionIgnoresFailureFromAbandonedTurn() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - int reported = 0; - QObject::connect(&session, &Session::Session::turnFailed, &session, [&reported](const QString &) { - ++reported; - }); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); - session.truncateRows(0); - - backend.script({Session::TurnFailed{"r1", "stale timeout"}}); - - QCOMPARE(reported, 0); -} - -void QodeAssistTest::testSessionTruncateRowsRewritesHistory() -{ - Chat::ChatModel model; - FakeChatBackend backend; - Session::Session session; - attachModelToSession(session, model); - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt); - backend.script(scriptedTurn("r1")); - - const qsizetype before = session.rows().size(); - QVERIFY(before > 1); - - session.truncateRows(1); - - QCOMPARE(session.rows().size(), qsizetype(1)); - QCOMPARE(model.rowCount(), 1); - QCOMPARE(session.history().size(), 1); - QCOMPARE(session.history().at(0).role, Session::MessageRole::User); - QCOMPARE(session.rows(), Session::projectToRows(session.history())); -} - -void QodeAssistTest::testSessionUpsertsAgentToolCalls() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Edit main.cpp", - .kind = "edit", - .status = "pending", - .fromAgent = true}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Edit main.cpp", - .kind = "edit", - .status = "completed", - .result = "done", - .details = QJsonObject{{"locations", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}}, - .fromAgent = true}}); - - QCOMPARE(session.history().size(), 2); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.blocks.size(), 1); - - const auto *tool = std::get_if(&assistant.blocks.at(0)); - QVERIFY(tool); - QCOMPARE(tool->id, QString("call-1")); - QCOMPARE(tool->name, QString("Edit main.cpp")); - QCOMPARE(tool->kind, QString("edit")); - QCOMPARE(tool->status, QString("completed")); - QCOMPARE(tool->result, QString("done")); - QVERIFY(tool->details.contains("locations")); - - QCOMPARE(session.rows().size(), 2); - QCOMPARE(session.rows().at(1).kind, Session::RowKind::AgentTool); - QCOMPARE(session.rows().at(1).toolStatus, QString("completed")); - QCOMPARE(session.rows().at(1).toolKind, QString("edit")); - QCOMPARE(session.rows().at(1).toolDetails, tool->details); - QVERIFY(tool->fromAgent); -} - -void QodeAssistTest::testLlmToolLifecycleFlowsThroughOneEvent() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - llmToolStarted("r1", "t1", "read_file", QJsonObject{{"path", "main.cpp"}})}); - - QCOMPARE(session.rows().size(), 2); - QCOMPARE(session.rows().at(1).kind, Session::RowKind::Tool); - QCOMPARE(session.rows().at(1).toolStatus, QString("in_progress")); - - backend.script( - {Session::TextDelta{"r1", "working"}, - llmToolCompleted("r1", "t1", "read_file", "int main() {}"), - Session::TextDelta{"r1", " still"}}); - - const Session::Message &assistant = session.history().at(1); - QCOMPARE(assistant.blocks.size(), 2); - - const auto *tool = std::get_if(&assistant.blocks.at(0)); - QVERIFY(tool); - QCOMPARE(tool->status, QString("completed")); - QCOMPARE(tool->result, QString("int main() {}")); - QCOMPARE(tool->arguments, (QJsonObject{{"path", "main.cpp"}})); - QVERIFY(!tool->fromAgent); - - QCOMPARE( - assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"working still"}})); - QCOMPARE(session.rows().at(1).toolStatus, QString("completed")); - QCOMPARE(session.rows(), Session::projectToRows(session.history())); -} - -void QodeAssistTest::testSessionKeepsOnePlanPerTurn() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"fix it"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::PlanUpdated{ - .turnId = "r1", - .entries = {Session::PlanEntry{"Read", "high", "in_progress"}, - Session::PlanEntry{"Fix", "medium", "pending"}}}, - Session::TextDelta{"r1", "working"}, - Session::PlanUpdated{ - .turnId = "r1", - .entries = {Session::PlanEntry{"Read", "high", "completed"}, - Session::PlanEntry{"Fix", "medium", "in_progress"}}}}); - - const Session::Message &assistant = session.history().at(1); - - qsizetype planBlocks = 0; - const Session::PlanBlock *plan = nullptr; - for (const Session::ContentBlock &block : assistant.blocks) { - if (const auto *candidate = std::get_if(&block)) { - ++planBlocks; - plan = candidate; - } - } - - QCOMPARE(planBlocks, qsizetype(1)); - QVERIFY(plan); - QCOMPARE(plan->entries.size(), 2); - QCOMPARE(plan->entries.at(0).status, QString("completed")); - QCOMPARE(plan->entries.at(1).status, QString("in_progress")); - - qsizetype planRows = 0; - for (const Session::MessageRow &row : session.rows()) { - if (row.kind == Session::RowKind::Plan) - ++planRows; - } - QCOMPARE(planRows, qsizetype(1)); -} - -void QodeAssistTest::testAgentActivityKeepsStreamedTextIntact() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Read main.cpp", - .status = "pending", - .fromAgent = true}, - Session::TextDelta{"r1", "Reading the file"}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Read main.cpp", - .status = "completed", - .fromAgent = true}, - Session::TextDelta{"r1", " and it is done."}}); - - const Session::Message &assistant = session.history().at(1); - - QStringList streamed; - for (const Session::ContentBlock &block : assistant.blocks) { - if (const auto *text = std::get_if(&block)) - streamed.append(text->text); - } - - QCOMPARE(streamed, QStringList{"Reading the file and it is done."}); -} - -void QodeAssistTest::testAgentToolUpdateTouchesOnlyItsOwnRow() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Read a", - .status = "pending", - .fromAgent = true}, - Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-2", - .name = "Read b", - .status = "pending", - .fromAgent = true}}); - - QList updatedRows; - QObject::connect( - &session, - &Session::Session::rowUpdated, - &session, - [&updatedRows](int index, const Session::MessageRow &) { updatedRows.append(index); }); - - backend.script( - {Session::ToolCallUpdated{ - .turnId = "r1", - .toolId = "call-1", - .name = "Read a", - .status = "completed", - .fromAgent = true}}); - - QCOMPARE(updatedRows, QList{1}); - QCOMPARE(session.rows().at(1).toolStatus, QString("completed")); - QCOMPARE(session.rows().at(2).toolStatus, QString("pending")); -} - -void QodeAssistTest::testUsageSkipsRowsThatCannotShowIt() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - Session::PlanUpdated{ - .turnId = "r1", .entries = {Session::PlanEntry{"Read", "high", "in_progress"}}}, - Session::TextDelta{"r1", "here is the answer"}, - Session::UsageReported{"r1", Session::Usage{120, 40, 80, 12}}}); - - const Session::MessageRow &planRow = session.rows().at(1); - const Session::MessageRow &textRow = session.rows().at(2); - - QCOMPARE(planRow.kind, Session::RowKind::Plan); - QVERIFY(planRow.usage.isEmpty()); - - QCOMPARE(textRow.kind, Session::RowKind::Assistant); - QCOMPARE(textRow.usage, (Session::Usage{120, 40, 80, 12})); -} - -void QodeAssistTest::testAgentActivityBlocksSurviveReload() -{ - Session::Message assistant; - assistant.role = Session::MessageRole::Assistant; - assistant.id = "r1"; - assistant.blocks - = {Session::PlanBlock{ - {Session::PlanEntry{"Read", "high", "completed"}, - Session::PlanEntry{"Fix", "medium", "in_progress"}}}, - Session::ToolCallBlock{ - "call-1", - "Edit main.cpp", - QJsonObject{}, - "done", - "edit", - "completed", - QJsonObject{{"diffs", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}}, - true}}; - - Session::ConversationHistory history; - history.append(assistant); - - const auto reloaded = Session::HistorySerializer::fromJson( - Session::HistorySerializer::toJson(history)); - QVERIFY(reloaded.has_value()); - QCOMPARE(*reloaded, history); - - const QList rows = Session::projectToRows(history); - QCOMPARE(rows.size(), 2); - QCOMPARE(rows.at(0).kind, Session::RowKind::Plan); - QCOMPARE(rows.at(1).kind, Session::RowKind::AgentTool); - QVERIFY(Session::isTranscriptOnlyRow(rows.at(0).kind)); - QVERIFY(Session::isTranscriptOnlyRow(rows.at(1).kind)); - QCOMPARE(Session::buildFromRows(rows), history); -} - -void QodeAssistTest::testEveryRowKindProjectsInOrder() -{ - QCOMPARE( - projectedKinds(), - (QList{ - Session::RowKind::System, - Session::RowKind::User, - Session::RowKind::Assistant, - Session::RowKind::Thinking, - Session::RowKind::Tool, - Session::RowKind::AgentTool, - Session::RowKind::FileEdit, - Session::RowKind::Permission, - Session::RowKind::Plan})); -} - -void QodeAssistTest::testPromptAudienceKeepsToolsAndThinking() -{ - QCOMPARE( - survivingKinds(Session::RowAudience::Prompt), - (QList{ - Session::RowKind::System, - Session::RowKind::User, - Session::RowKind::Assistant, - Session::RowKind::Thinking, - Session::RowKind::Tool})); - - QCOMPARE( - Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::User), - Session::RowTreatment::UserText); - QCOMPARE( - Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::System), - Session::RowTreatment::AssistantText); - QCOMPARE( - Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::Thinking), - Session::RowTreatment::AssistantThinking); - QCOMPARE( - Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::Tool), - Session::RowTreatment::ToolExchange); -} - -void QodeAssistTest::testCompressionAudienceDropsToolsAndThinking() -{ - QCOMPARE( - survivingKinds(Session::RowAudience::Compression), - (QList{ - Session::RowKind::System, Session::RowKind::User, Session::RowKind::Assistant})); -} - -void QodeAssistTest::testTokenCountAudienceCountsToolsAndThinking() -{ - QCOMPARE( - survivingKinds(Session::RowAudience::TokenCount), - (QList{ - Session::RowKind::System, - Session::RowKind::User, - Session::RowKind::Assistant, - Session::RowKind::Thinking, - Session::RowKind::Tool})); -} - -void QodeAssistTest::testSessionRendersPermissionRequestWithItsOptions() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); - - QCOMPARE(session.rows().size(), 2); - QCOMPARE(session.rows().at(1).kind, Session::RowKind::Permission); - QCOMPARE(session.rows().at(1).id, QString("p1")); - - const Session::PermissionBlock block = permissionRowAt(session, 1); - QCOMPARE(block.requestId, QString("p1")); - QCOMPARE(block.toolCallId, QString("tool-p1")); - QCOMPARE(block.title, QString("Edit main.cpp")); - QCOMPARE(block.toolKind, QString("edit")); - QCOMPARE(block.options, editPermissionOptions()); - QCOMPARE(block.status, Session::PermissionStatus::Pending); - QVERIFY(!block.automatic); - QVERIFY(session.isPermissionPending("p1")); - - QVERIFY(backend.permissionAnswers().isEmpty()); -} - -void QodeAssistTest::testSessionForwardsPermissionAnswerToTheBackend() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); - - session.respondPermission("p1", "opt-no"); - - QCOMPARE(backend.permissionAnswers().size(), 1); - QCOMPARE(backend.permissionAnswers().at(0).first, QString("p1")); - QCOMPARE(backend.permissionAnswers().at(0).second, QString("opt-no")); - - const Session::PermissionBlock block = permissionRowAt(session, 1); - QCOMPARE(block.status, Session::PermissionStatus::Answered); - QCOMPARE(block.selectedOptionId, QString("opt-no")); - QVERIFY(!block.automatic); - QVERIFY(!session.isPermissionPending("p1")); -} - -void QodeAssistTest::testSessionAllowAlwaysSuppressesRepeatPromptsOfTheSameKind() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"edit them"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); - - session.respondPermission("p1", "opt-always"); - - backend.script({editPermissionRequest("r1", "p2")}); - - QCOMPARE(backend.permissionAnswers().size(), 2); - QCOMPARE(backend.permissionAnswers().at(1).first, QString("p2")); - QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-once")); - - const Session::PermissionBlock block = permissionRowAt(session, 2); - QCOMPARE(block.requestId, QString("p2")); - QCOMPARE(block.status, Session::PermissionStatus::Answered); - QCOMPARE(block.selectedOptionId, QString("opt-once")); - QVERIFY(block.automatic); - QVERIFY(!session.isPermissionPending("p2")); -} - -void QodeAssistTest::testSessionAllowAlwaysDoesNotCoverOtherToolKinds() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", "edit")}); - - session.respondPermission("p1", "opt-always"); - - backend.script({editPermissionRequest("r1", "p2", "execute")}); - - QCOMPARE(backend.permissionAnswers().size(), 1); - QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); - QVERIFY(session.isPermissionPending("p2")); -} - -void QodeAssistTest::testSessionNeverAutoAnswersUnclassifiedTools() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", QString())}); - - session.respondPermission("p1", "opt-always"); - - backend.script({editPermissionRequest("r1", "p2", QString())}); - - QCOMPARE(backend.permissionAnswers().size(), 1); - QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); -} - -void QodeAssistTest::testSessionIgnoresAnswersToUnknownPermissionRequests() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); - - session.respondPermission("nonexistent", "opt-once"); - session.respondPermission("p1", "no-such-option"); - session.respondPermission("p1", "opt-once"); - session.respondPermission("p1", "opt-no"); - - QCOMPARE(backend.permissionAnswers().size(), 1); - QCOMPARE(backend.permissionAnswers().at(0).second, QString("opt-once")); - QCOMPARE(permissionRowAt(session, 1).selectedOptionId, QString("opt-once")); -} - -void QodeAssistTest::testSessionMarksUnansweredPermissionsCancelled() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); - - backend.script( - {Session::PermissionResolved{.turnId = "r1", .requestId = "p1", .cancelled = true}, - Session::TurnFailed{"r1", "the agent went away"}}); - - const Session::PermissionBlock block = permissionRowAt(session, 1); - QCOMPARE(block.status, Session::PermissionStatus::Cancelled); - QVERIFY(block.selectedOptionId.isEmpty()); - QVERIFY(!session.isPermissionPending("p1")); - - session.respondPermission("p1", "opt-once"); - QVERIFY(backend.permissionAnswers().isEmpty()); -} - -void QodeAssistTest::testSessionRejectAlwaysSuppressesRepeatPromptsOfTheSameKind() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - Session::PermissionRequested first = editPermissionRequest("r1", "p1"); - first.options.append( - Session::PermissionOption{"opt-never", "No, and don't ask again", "reject_always"}); - - session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, first}); - - session.respondPermission("p1", "opt-never"); - - Session::PermissionRequested second = editPermissionRequest("r1", "p2"); - second.options.append( - Session::PermissionOption{"opt-never", "No, and don't ask again", "reject_always"}); - backend.script({second}); - - QCOMPARE(backend.permissionAnswers().size(), 2); - QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-no")); - - const Session::PermissionBlock block = permissionRowAt(session, 2); - QCOMPARE(block.status, Session::PermissionStatus::Answered); - QCOMPARE(block.selectedOptionId, QString("opt-no")); - QVERIFY(block.automatic); -} - -void QodeAssistTest::testSessionAutoAnswerFallsBackToTheAlwaysOption() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); - session.respondPermission("p1", "opt-always"); - - Session::PermissionRequested withoutOnce = editPermissionRequest("r1", "p2"); - withoutOnce.options = { - Session::PermissionOption{"opt-always", "Yes, always", "allow_always"}, - Session::PermissionOption{"opt-no", "No", "reject_once"}}; - backend.script({withoutOnce}); - - QCOMPARE(backend.permissionAnswers().size(), 2); - QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-always")); -} - -void QodeAssistTest::testSessionDoesNotAutoAnswerWithoutAMatchingOption() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); - session.respondPermission("p1", "opt-always"); - - Session::PermissionRequested rejectOnly = editPermissionRequest("r1", "p2"); - rejectOnly.options = {Session::PermissionOption{"opt-no", "No", "reject_once"}}; - backend.script({rejectOnly}); - - QCOMPARE(backend.permissionAnswers().size(), 1); - QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); - QVERIFY(session.isPermissionPending("p2")); -} - -void QodeAssistTest::testSessionAllowAlwaysDoesNotSurviveAnotherConversation() -{ - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); - session.respondPermission("p1", "opt-always"); - QCOMPARE(backend.permissionAnswers().size(), 1); - - session.setHistory(Session::ConversationHistory{}); - - session.sendTurn({Session::TextBlock{"work again"}}, std::nullopt); - backend.script({Session::TurnStarted{"r2"}, editPermissionRequest("r2", "p2")}); - - QCOMPARE(backend.permissionAnswers().size(), 1); - QCOMPARE(permissionRowAt(session, 1).status, Session::PermissionStatus::Pending); -} - -void QodeAssistTest::testSessionAnswersTheLiveRequestWhenAnOldIdIsReused() -{ - Session::Message assistant; - assistant.role = Session::MessageRole::Assistant; - assistant.id = "old"; - assistant.blocks - = {Session::PermissionBlock{ - .requestId = "p1", - .toolCallId = "tool-old", - .title = "Run make", - .toolKind = "execute", - .options = editPermissionOptions(), - .status = Session::PermissionStatus::Cancelled}}; - - Session::ConversationHistory history; - history.append(assistant); - - FakeChatBackend backend; - Session::Session session; - session.setBackend(&backend); - session.setHistory(history); - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", "edit")}); - - session.respondPermission("p1", "opt-always"); - - QCOMPARE(backend.permissionAnswers().size(), 1); - - const Session::PermissionBlock stale = permissionRowAt(session, 0); - QCOMPARE(stale.status, Session::PermissionStatus::Cancelled); - QVERIFY(stale.selectedOptionId.isEmpty()); - - const Session::PermissionBlock live = permissionRowAt(session, 2); - QCOMPARE(live.status, Session::PermissionStatus::Answered); - QCOMPARE(live.selectedOptionId, QString("opt-always")); - - backend.script({editPermissionRequest("r1", "p3", "edit")}); - QCOMPARE(backend.permissionAnswers().size(), 2); - - backend.script({editPermissionRequest("r1", "p4", "execute")}); - QCOMPARE(backend.permissionAnswers().size(), 2); -} - -void QodeAssistTest::testSessionCancelsPermissionsTheBackendNeverResolved() -{ - FakeChatBackend backend; - backend.setAutoResolvePermissions(false); - - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script( - {Session::TurnStarted{"r1"}, - editPermissionRequest("r1", "p1"), - Session::TurnCompleted{"r1"}}); - - QCOMPARE(permissionRowAt(session, 1).status, Session::PermissionStatus::Cancelled); - QVERIFY(!session.isPermissionPending("p1")); -} - -void QodeAssistTest::testSessionCancelsPermissionsTheBackendRefused() -{ - FakeChatBackend backend; - backend.setAcceptPermissionAnswers(false); - - Session::Session session; - session.setBackend(&backend); - - session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); - backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); - - session.respondPermission("p1", "opt-always"); - - const Session::PermissionBlock block = permissionRowAt(session, 1); - QCOMPARE(block.status, Session::PermissionStatus::Cancelled); - QVERIFY(block.selectedOptionId.isEmpty()); - QVERIFY(!session.isPermissionPending("p1")); - - backend.script({editPermissionRequest("r1", "p2")}); - QCOMPARE(backend.permissionAnswers().size(), 1); - QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); -} - -void QodeAssistTest::testPermissionBlockReopensAsUnanswerable() -{ - Session::Message assistant; - assistant.role = Session::MessageRole::Assistant; - assistant.id = "r1"; - assistant.blocks - = {Session::PermissionBlock{ - .requestId = "p1", - .toolCallId = "tool-1", - .title = "Edit main.cpp", - .toolKind = "edit", - .options = editPermissionOptions(), - .status = Session::PermissionStatus::Answered, - .selectedOptionId = "opt-once", - .automatic = false}, - Session::PermissionBlock{ - .requestId = "p2", - .toolCallId = "tool-2", - .title = "Run make", - .toolKind = "execute", - .options = editPermissionOptions(), - .status = Session::PermissionStatus::Pending}}; - - Session::ConversationHistory history; - history.append(assistant); - - const QJsonObject json = Session::HistorySerializer::toJson(history); - const auto reloaded = Session::HistorySerializer::fromJson(json); - QVERIFY(reloaded.has_value()); - QCOMPARE(reloaded->size(), 1); - QCOMPARE(reloaded->at(0).blocks.size(), 2); - - const auto *answered = std::get_if(&reloaded->at(0).blocks.at(0)); - QVERIFY(answered); - QCOMPARE(*answered, std::get(assistant.blocks.at(0))); - - const auto *stale = std::get_if(&reloaded->at(0).blocks.at(1)); - QVERIFY(stale); - QCOMPARE(stale->status, Session::PermissionStatus::Cancelled); - QCOMPARE(stale->options, editPermissionOptions()); - - const QList rows = Session::projectToRows(*reloaded); - QCOMPARE(rows.size(), 2); - QCOMPARE(rows.at(0).kind, Session::RowKind::Permission); - - const Session::ConversationHistory rebuilt = Session::buildFromRows(rows); - QCOMPARE(rebuilt.size(), 1); - QCOMPARE(rebuilt.at(0).blocks, reloaded->at(0).blocks); -} - -void QodeAssistTest::testTurnContextCollectsPartsFromPorts() -{ - FakeProjectContext projectPort(sampleProject()); - FakeSkillsContext skillsPort( - "# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}}); - - Session::TurnContextRequest request; - request.message = "please /review this"; - request.basePrompt = "You are a helpful assistant."; - - const Session::TurnContext context - = Session::TurnContextBuilder(projectPort, &skillsPort).build(request); - - QCOMPARE(context.basePrompt, QString("You are a helpful assistant.")); - QVERIFY(context.project.available); - QCOMPARE(context.project.displayName, QString("QodeAssist")); - QCOMPARE(context.alwaysOnSkills, QString("# Always on")); - QCOMPARE(context.skillsCatalog, QString("# Skills catalog")); - QCOMPARE(context.invokedSkills.size(), 1); - QCOMPARE(context.invokedSkills.at(0).name, QString("review")); -} - -void QodeAssistTest::testTurnContextSkipsWhatTheBackendDoesNotNeed() -{ - FakeProjectContext projectPort(sampleProject()); - FakeSkillsContext skillsPort( - "# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}}); - - Session::TurnContextRequest request; - request.message = "please /review this"; - request.needs = Session::TurnContextNeeds{false}; - - const Session::TurnContext context - = Session::TurnContextBuilder(projectPort, &skillsPort).build(request); - - QVERIFY(context.alwaysOnSkills.isEmpty()); - QVERIFY(context.skillsCatalog.isEmpty()); - QVERIFY(context.invokedSkills.isEmpty()); -} - -void QodeAssistTest::testFencedFileBlockOutgrowsBackticksInContent() -{ - const QString plain = Session::fencedFileBlock("notes.txt", "hello"); - QCOMPARE(plain, QString("File: notes.txt\n```\nhello\n```")); - - const QString fenced = Session::fencedFileBlock("readme.md", "text\n```\ncode\n```\nmore"); - QVERIFY(fenced.startsWith("File: readme.md\n````\n")); - QVERIFY(fenced.endsWith("\n````")); - QVERIFY(fenced.contains("```\ncode\n```")); - - const QString nested = Session::fencedFileBlock("deep.md", "`````"); - QVERIFY(nested.contains("``````")); -} - -void QodeAssistTest::testTurnContextSkillCommandScanning() -{ - FakeProjectContext projectPort({}); - FakeSkillsContext skillsPort( - QString(), - QString(), - {Session::InvokedSkill{"review", "Review body"}, - Session::InvokedSkill{"docs", "Docs body"}, - Session::InvokedSkill{"blank", QString()}}); - - Session::TurnContextRequest request; - request.message = "/review then /docs then /review again /blank /unknown and mid/word"; - - const Session::TurnContext context - = Session::TurnContextBuilder(projectPort, &skillsPort).build(request); - - QCOMPARE(context.invokedSkills.size(), 2); - QCOMPARE(context.invokedSkills.at(0).name, QString("review")); - QCOMPARE(context.invokedSkills.at(1).name, QString("docs")); -} - -void QodeAssistTest::testTurnContextWithoutSkillsPort() -{ - FakeProjectContext projectPort(sampleProject()); - - Session::TurnContextRequest request; - request.message = "/review this"; - request.basePrompt = "base"; - - const Session::TurnContext context - = Session::TurnContextBuilder(projectPort, nullptr).build(request); - - QVERIFY(context.alwaysOnSkills.isEmpty()); - QVERIFY(context.skillsCatalog.isEmpty()); - QVERIFY(context.invokedSkills.isEmpty()); -} - -void QodeAssistTest::testSystemPromptRenderingWithProject() -{ - Session::TurnContext context; - context.basePrompt = "You are helpful."; - context.project = sampleProject(); - context.alwaysOnSkills = "# Always on"; - context.skillsCatalog = "# Catalog"; - context.invokedSkills = {Session::InvokedSkill{"review", "Review body"}}; - - const QString expected - = "You are helpful." - "\n# Active project: QodeAssist" - "\n# Project source root: /src/QodeAssist" - "\n# All new source files, headers, QML and CMake edits MUST be created or modified " - "under this directory. Use absolute paths rooted here, or project-relative paths." - "\n# Build output directory (compiler artifacts only — do NOT create or edit source " - "files here): /src/QodeAssist/build" - "\n\n# Always on" - "\n\n# Catalog" - "\n\n# Invoked Skill: review\n\nReview body"; - - QCOMPARE(Session::renderSystemPrompt(context), expected); -} - -void QodeAssistTest::testSystemPromptRenderingWithoutProject() -{ - Session::TurnContext context; - context.basePrompt = "You are helpful."; - - QCOMPARE( - Session::renderSystemPrompt(context), - QString("You are helpful.\n# No active project in IDE")); -} - -void QodeAssistTest::testAgentRegistryParsesEveryDistributionKind() -{ - const QByteArray payload = R"({"version":"1.0.0","agents":[ - {"id":"npx-agent","name":"Npx Agent","version":"1.2.3","description":"An agent", - "authors":["Ada","Grace"],"license":"MIT","repository":"https://example.com/repo", - "website":"https://example.com","icon":"https://example.com/icon.svg", - "distribution":{"npx":{"package":"pkg@1.2.3","args":["--acp"],"env":{"KEY":"VALUE"}}}}, - {"id":"uvx-agent","name":"Uvx Agent", - "distribution":{"uvx":{"package":"pypkg==1.0","args":["-x"]}}}, - {"id":"binary-agent","name":"Binary Agent","distribution":{"binary":{ - "darwin-aarch64":{"archive":"https://example.com/a.tar.gz","sha256":"abc", - "cmd":"./dist/agent","args":["acp"]}}}}, - {"id":"command-agent","name":"Command Agent", - "distribution":{"command":{"cmd":"/usr/local/bin/agent","args":["acp"]}}} - ]})"; - - const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse( - payload, Acp::AgentSource::LiveRegistry, "registry"); - - QVERIFY(result.warnings.isEmpty()); - QCOMPARE(result.agents.size(), 4); - - const Acp::AgentDefinition &npx = result.agents.at(0); - QCOMPARE(npx.id, QString("npx-agent")); - QCOMPARE(npx.name, QString("Npx Agent")); - QCOMPARE(npx.version, QString("1.2.3")); - QCOMPARE(npx.description, QString("An agent")); - QCOMPARE(npx.authors, (QStringList{"Ada", "Grace"})); - QCOMPARE(npx.license, QString("MIT")); - QCOMPARE(npx.repository, QString("https://example.com/repo")); - QCOMPARE(npx.source, Acp::AgentSource::LiveRegistry); - QCOMPARE(npx.origin, QString("registry")); - QCOMPARE(npx.distribution.kind, Acp::AgentDistributionKind::Npx); - QCOMPARE(npx.distribution.package, QString("pkg@1.2.3")); - QCOMPARE(npx.distribution.args, (QStringList{"--acp"})); - QCOMPARE(npx.distribution.env.size(), 1); - QCOMPARE(npx.distribution.env.at(0).name, QString("KEY")); - QCOMPARE(npx.distribution.env.at(0).value, QString("VALUE")); - QVERIFY(npx.isLaunchable()); - - const Acp::AgentDefinition &uvx = result.agents.at(1); - QCOMPARE(uvx.distribution.kind, Acp::AgentDistributionKind::Uvx); - QCOMPARE(uvx.distribution.package, QString("pypkg==1.0")); - QVERIFY(uvx.isLaunchable()); - - const Acp::AgentDefinition &binary = result.agents.at(2); - QCOMPARE(binary.distribution.kind, Acp::AgentDistributionKind::Binary); - QCOMPARE(binary.distribution.binaryTargets.size(), 1); - QCOMPARE(binary.distribution.binaryTargets.at(0).platform, QString("darwin-aarch64")); - QCOMPARE(binary.distribution.binaryTargets.at(0).cmd, QString("./dist/agent")); - QCOMPARE(binary.distribution.binaryTargets.at(0).sha256, QString("abc")); - QVERIFY(!binary.isLaunchable()); - - const Acp::AgentDefinition &command = result.agents.at(3); - QCOMPARE(command.distribution.kind, Acp::AgentDistributionKind::Command); - QCOMPARE(command.distribution.command, QString("/usr/local/bin/agent")); - QVERIFY(command.isLaunchable()); -} - -void QodeAssistTest::testAgentRegistryReportsUnusableEntries() -{ - const QByteArray payload = R"({"agents":[ - {"name":"No Id","distribution":{"npx":{"package":"p"}}}, - {"id":"no-distribution","name":"No Distribution"}, - {"id":"usable","name":"Usable","distribution":{"npx":{"package":"p"}}} - ]})"; - - const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse( - payload, Acp::AgentSource::LiveRegistry, "registry"); - - QCOMPARE(result.agents.size(), 2); - QCOMPARE(result.warnings.size(), 2); - QVERIFY(result.warnings.at(0).contains("entry 0")); - QVERIFY(result.warnings.at(1).contains("no-distribution")); - - QCOMPARE(result.agents.at(0).id, QString("no-distribution")); - QVERIFY(!result.agents.at(0).isLaunchable()); - QVERIFY(result.agents.at(1).isLaunchable()); -} - -void QodeAssistTest::testAgentRegistryParsesSingleAgentUserFile() -{ - const QByteArray payload - = R"({"id":"local","distribution":{"command":{"cmd":"/opt/local-agent"}}})"; - - const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse( - payload, Acp::AgentSource::UserFile, "local.json"); - - QCOMPARE(result.agents.size(), 1); - QCOMPARE(result.agents.at(0).id, QString("local")); - QCOMPARE(result.agents.at(0).name, QString("local")); - QCOMPARE(result.agents.at(0).source, Acp::AgentSource::UserFile); - QVERIFY(result.agents.at(0).isLaunchable()); -} - -void QodeAssistTest::testAgentCatalogAppliesMergePriority() -{ - const QByteArray bundled = R"({"agents":[ - {"id":"alpha","name":"Alpha Bundled","distribution":{"npx":{"package":"alpha@1"}}}, - {"id":"beta","name":"Beta","distribution":{"npx":{"package":"beta@1"}}} - ]})"; - const QByteArray live = R"({"agents":[ - {"id":"alpha","name":"Alpha Registry","distribution":{"npx":{"package":"alpha@2"}}}, - {"id":"gamma","name":"Gamma","distribution":{"uvx":{"package":"gamma"}}} - ]})"; - const QByteArray user - = R"({"id":"alpha","name":"Alpha User","distribution":{"command":{"cmd":"/opt/alpha"}}})"; - - Acp::AgentCatalog catalog; - catalog.setLayer( - Acp::AgentSource::BundledSnapshot, - Acp::AgentRegistryParser::parse(bundled, Acp::AgentSource::BundledSnapshot, "bundled") - .agents); - catalog.setLayer( - Acp::AgentSource::LiveRegistry, - Acp::AgentRegistryParser::parse(live, Acp::AgentSource::LiveRegistry, "registry").agents); - catalog.setLayer( - Acp::AgentSource::UserFile, - Acp::AgentRegistryParser::parse(user, Acp::AgentSource::UserFile, "alpha.json").agents); - - QCOMPARE(catalog.size(), 3); - - QStringList names; - for (const Acp::AgentDefinition &agent : catalog.agents()) - names.append(agent.name); - QCOMPARE(names, (QStringList{"Alpha User", "Beta", "Gamma"})); - - auto alpha = catalog.agent("alpha"); - QVERIFY(alpha.has_value()); - QCOMPARE(alpha->source, Acp::AgentSource::UserFile); - QCOMPARE(alpha->distribution.kind, Acp::AgentDistributionKind::Command); - - catalog.setLayer(Acp::AgentSource::UserFile, {}); - alpha = catalog.agent("alpha"); - QVERIFY(alpha.has_value()); - QCOMPARE(alpha->name, QString("Alpha Registry")); - QCOMPARE(alpha->distribution.package, QString("alpha@2")); - - catalog.setLayer(Acp::AgentSource::LiveRegistry, {}); - alpha = catalog.agent("alpha"); - QVERIFY(alpha.has_value()); - QCOMPARE(alpha->name, QString("Alpha Bundled")); -} - -void QodeAssistTest::testAgentCatalogUserFileMakesBinaryAgentLaunchable() -{ - const QByteArray live = R"({"agents":[{"id":"cursor","name":"Cursor", - "distribution":{"binary":{"darwin-aarch64":{"archive":"https://example.com/a.tar.gz", - "cmd":"./cursor-agent","args":["acp"]}}}}]})"; - const QByteArray user = R"({"id":"cursor","name":"Cursor", - "distribution":{"command":{"cmd":"/usr/local/bin/cursor-agent","args":["acp"]}}})"; - - Acp::AgentCatalog catalog; - catalog.setLayer( - Acp::AgentSource::LiveRegistry, - Acp::AgentRegistryParser::parse(live, Acp::AgentSource::LiveRegistry, "registry").agents); - QVERIFY(catalog.launchableAgents().isEmpty()); - - catalog.setLayer( - Acp::AgentSource::UserFile, - Acp::AgentRegistryParser::parse(user, Acp::AgentSource::UserFile, "cursor.json").agents); - - QCOMPARE(catalog.size(), 1); - QCOMPARE(catalog.launchableAgents().size(), 1); - QCOMPARE(catalog.agent("cursor")->distribution.command, QString("/usr/local/bin/cursor-agent")); -} - -void QodeAssistTest::testAgentLaunchConfigUsesRunnerConventions() -{ - Acp::AgentDefinition npx; - npx.id = "npx-agent"; - npx.distribution.kind = Acp::AgentDistributionKind::Npx; - npx.distribution.package = "pkg@1.0"; - npx.distribution.args = QStringList{"--acp"}; - npx.distribution.env = {{"KEY", "VALUE"}}; - - const auto npxConfig = Acp::agentLaunchConfig(npx, "/tmp/project"); - QVERIFY(npxConfig.has_value()); - QCOMPARE(npxConfig->cwd, QString("/tmp/project")); - QCOMPARE(npxConfig->command, QString("npx")); - QCOMPARE(npxConfig->args, (QStringList{"-y", "pkg@1.0", "--acp"})); - QCOMPARE(npxConfig->env.size(), 1); - QCOMPARE(npxConfig->env.at(0).name, QString("KEY")); - - Acp::AgentDefinition uvx; - uvx.id = "uvx-agent"; - uvx.distribution.kind = Acp::AgentDistributionKind::Uvx; - uvx.distribution.package = "pypkg==1.0"; - uvx.distribution.args = QStringList{"-x"}; - - const auto uvxConfig = Acp::agentLaunchConfig(uvx, QString()); - QVERIFY(uvxConfig.has_value()); - QCOMPARE(uvxConfig->command, QString("uvx")); - QCOMPARE(uvxConfig->args, (QStringList{"pypkg==1.0", "-x"})); - - Acp::AgentDefinition command; - command.id = "command-agent"; - command.distribution.kind = Acp::AgentDistributionKind::Command; - command.distribution.command = "/opt/agent"; - command.distribution.args = QStringList{"acp"}; - - const auto commandConfig = Acp::agentLaunchConfig(command, QString()); - QVERIFY(commandConfig.has_value()); - QCOMPARE(commandConfig->command, QString("/opt/agent")); - QCOMPARE(commandConfig->args, (QStringList{"acp"})); - - Acp::AgentDefinition binary; - binary.id = "binary-agent"; - binary.distribution.kind = Acp::AgentDistributionKind::Binary; - QVERIFY(!Acp::agentLaunchConfig(binary, QString()).has_value()); -} - -void QodeAssistTest::testAgentSearchPathsSplitting() -{ - const QString separator(QDir::listSeparator()); - - QCOMPARE(Acp::splitSearchPaths(QString()), QStringList()); - QCOMPARE( - Acp::splitSearchPaths(" /opt/homebrew/bin " + separator + separator + "/usr/local/bin"), - (QStringList{"/opt/homebrew/bin", "/usr/local/bin"})); -} - -void QodeAssistTest::testAgentExtraSearchPathsReachTheChildEnvironment() -{ - Acp::AgentDefinition agent; - agent.id = "npx-agent"; - agent.distribution.kind = Acp::AgentDistributionKind::Npx; - agent.distribution.package = "pkg"; - - auto config = Acp::agentLaunchConfig(agent, QString()); - QVERIFY(config.has_value()); - - Acp::applyExtraSearchPaths(*config, {}); - QVERIFY(config->env.isEmpty()); - QCOMPARE(config->command, QString("npx")); - - Acp::applyExtraSearchPaths(*config, QStringList{"/no/such/qodeassist/dir"}); - QCOMPARE(config->command, QString("npx")); - QCOMPARE(config->env.size(), 1); - QCOMPARE(config->env.at(0).name, QString("PATH")); - QVERIFY(config->env.at(0).value.startsWith("/no/such/qodeassist/dir")); - - Acp::AgentDefinition withOwnPath; - withOwnPath.id = "own-path"; - withOwnPath.distribution.kind = Acp::AgentDistributionKind::Npx; - withOwnPath.distribution.package = "pkg"; - withOwnPath.distribution.env = {{"PATH", "/agent/own"}}; - - auto ownPathConfig = Acp::agentLaunchConfig(withOwnPath, QString()); - QVERIFY(ownPathConfig.has_value()); - Acp::applyExtraSearchPaths(*ownPathConfig, QStringList{"/extra"}); - - QCOMPARE(ownPathConfig->env.size(), 2); - QCOMPARE(ownPathConfig->env.at(0).name, QString("PATH")); - QCOMPARE(ownPathConfig->env.at(1).value, QString("/agent/own")); -} - -void QodeAssistTest::testAgentForwardedVariablesReachTheChildEnvironment() -{ - QCOMPARE( - Acp::splitVariableNames(" FOO, BAR\tBAZ "), (QStringList{"FOO", "BAR", "BAZ"})); - QCOMPARE(Acp::splitVariableNames(QString()), QStringList()); - - qputenv("QODEASSIST_TEST_TOKEN", "from-environment"); - - Acp::AgentDefinition agent; - agent.id = "npx-agent"; - agent.distribution.kind = Acp::AgentDistributionKind::Npx; - agent.distribution.package = "pkg"; - agent.distribution.env = {{"QODEASSIST_TEST_TOKEN", "from-definition"}}; - - auto config = Acp::agentLaunchConfig(agent, QString()); - QVERIFY(config.has_value()); - - Acp::applyForwardedEnvironment(*config, {}); - QCOMPARE(config->env.size(), 1); - - Acp::applyForwardedEnvironment(*config, QStringList{"QODEASSIST_TEST_TOKEN"}); - - QCOMPARE(config->env.size(), 2); - QCOMPARE(config->env.at(0).name, QString("QODEASSIST_TEST_TOKEN")); - QCOMPARE(config->env.at(0).value, QString("from-environment")); - QCOMPARE(config->env.at(1).value, QString("from-definition")); - - qunsetenv("QODEASSIST_TEST_TOKEN"); -} - -void QodeAssistTest::testBundledAgentSnapshotParses() -{ - QFile file(Acp::AgentCatalogStore::bundledSnapshotPath()); - QVERIFY2(file.open(QIODevice::ReadOnly), qPrintable(file.fileName())); - - const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse( - file.readAll(), Acp::AgentSource::BundledSnapshot, "bundled snapshot"); - - QVERIFY(result.agents.size() > 10); - QVERIFY2(result.warnings.isEmpty(), qPrintable(result.warnings.join("; "))); - - Acp::AgentCatalog catalog; - catalog.setLayer(Acp::AgentSource::BundledSnapshot, result.agents); - QVERIFY(!catalog.launchableAgents().isEmpty()); -} - -namespace { - -Acp::AgentDefinition fakeAgentDefinition() -{ - Acp::AgentDefinition agent; - agent.id = "fake-agent"; - agent.name = "Fake Agent"; - agent.distribution.kind = Acp::AgentDistributionKind::Command; - agent.distribution.command = "/bin/true"; - return agent; -} - -class AcpBackendFixture -{ -public: - AcpBackendFixture() - { - backend.setClientFactory( - [this](const Acp::AgentDefinition &, const QString &cwd, QObject *parent) { - requestedCwd = cwd; - agent = new FakeAcpAgent(parent); - configure(agent); - return Acp::AgentProcess{ - new LLMQore::Acp::AcpClient( - agent, - {QStringLiteral("QodeAssistTest"), QStringLiteral("1.0"), QString()}, - parent), - QStringLiteral("fake")}; - }); - - QObject::connect( - &backend, - &Session::ChatBackend::sessionEvent, - &backend, - [this](const Session::SessionEvent &event) { events.append(event); }); - - backend.setStoredContentLoader([this](const QString &, const QString &storedPath) { - return storage.value(storedPath); - }); - - backend.bindAgent(fakeAgentDefinition()); - } - - void send(const QString &text) - { - Session::TurnRequest request; - request.userBlocks = {Session::TextBlock{text}}; - backend.sendTurn(request); - } - - void sendBlocks( - const QList &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 - -void QodeAssistTest::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 QodeAssistTest::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 QodeAssistTest::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 QodeAssistTest::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 QodeAssistTest::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 QodeAssistTest::testAcpBackendSendsAttachmentsAsEmbeddedResources() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); }; - fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk")); - - fixture.sendBlocks( - {Session::TextBlock{"summarise this"}, - Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}}); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - const auto resources = fixture.promptBlocksOfType("resource"); - QCOMPARE(resources.size(), 1); - - const QJsonObject resource = resources.at(0).value("resource").toObject(); - QCOMPARE(resource.value("text").toString(), QString("remember the milk")); - QCOMPARE(resource.value("mimeType").toString(), QString("text/plain")); - QVERIFY(resource.value("uri").toString().endsWith("notes.txt")); - - QCOMPARE(fixture.promptBlocksOfType("text").size(), 1); -} - -void QodeAssistTest::testAcpBackendInlinesAttachmentsWithoutEmbeddedContextSupport() -{ - AcpBackendFixture fixture; - fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk")); - - fixture.sendBlocks( - {Session::TextBlock{"summarise this"}, - Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}}); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - QCOMPARE(fixture.promptBlocksOfType("resource").size(), 0); - - const auto texts = fixture.promptBlocksOfType("text"); - QCOMPARE(texts.size(), 2); - - const QString inlined = texts.at(1).value("text").toString(); - QVERIFY(inlined.contains("notes.txt")); - QVERIFY(inlined.contains("remember the milk")); -} - -void QodeAssistTest::testAcpBackendReportsAttachmentsItCannotLoad() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); }; - - fixture.sendBlocks( - {Session::TextBlock{"summarise this"}, - Session::AttachmentBlock{"notes.txt", "missing.txt"}}); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - QCOMPARE(fixture.promptBlocksOfType("resource").size(), 0); - - const auto texts = fixture.promptBlocksOfType("text"); - QCOMPARE(texts.size(), 2); - QVERIFY(texts.at(1).value("text").toString().contains("notes.txt")); -} - -void QodeAssistTest::testAcpBackendSendsImagesWhenTheAgentAcceptsThem() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsImages(true); }; - fixture.storage.insert("shot_cd34.png", QByteArray("\x89PNG-bytes")); - - fixture.sendBlocks( - {Session::TextBlock{"what is this"}, - Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - const auto images = fixture.promptBlocksOfType("image"); - QCOMPARE(images.size(), 1); - QCOMPARE(images.at(0).value("mimeType").toString(), QString("image/png")); - QCOMPARE( - images.at(0).value("data").toString(), - QString::fromLatin1(QByteArray("\x89PNG-bytes").toBase64())); -} - -void QodeAssistTest::testAcpBackendNamesImagesTheAgentCannotAccept() -{ - AcpBackendFixture fixture; - fixture.storage.insert("shot_cd34.png", QByteArray("\x89PNG-bytes")); - - fixture.sendBlocks( - {Session::TextBlock{"what is this"}, - Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - QCOMPARE(fixture.promptBlocksOfType("image").size(), 0); - - const auto texts = fixture.promptBlocksOfType("text"); - QCOMPARE(texts.size(), 2); - QVERIFY(texts.at(1).value("text").toString().contains("shot.png")); -} - -void QodeAssistTest::testAcpBackendFencesAttachmentsThatContainFences() -{ - AcpBackendFixture fixture; - fixture.storage.insert("readme_ab12.md", QByteArray("intro\n```\ncode\n```\noutro")); - - fixture.sendBlocks( - {Session::TextBlock{"read it"}, Session::AttachmentBlock{"readme.md", "readme_ab12.md"}}); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - const auto texts = fixture.promptBlocksOfType("text"); - QCOMPARE(texts.size(), 2); - - const QString inlined = texts.at(1).value("text").toString(); - QVERIFY(inlined.startsWith("File: readme.md\n````\n")); - QVERIFY(inlined.endsWith("\n````")); - QVERIFY(inlined.contains("```\ncode\n```")); -} - -void QodeAssistTest::testAcpBackendTruncatesOversizedAttachments() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); }; - - const QByteArray huge(700 * 1024, 'x'); - fixture.storage.insert("dump_ab12.log", huge); - - fixture.sendBlocks( - {Session::TextBlock{"summarise"}, Session::AttachmentBlock{"dump.log", "dump_ab12.log"}}); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - const auto resources = fixture.promptBlocksOfType("resource"); - QCOMPARE(resources.size(), 1); - - const QString text = resources.at(0).value("resource").toObject().value("text").toString(); - QVERIFY(text.size() < huge.size()); - QVERIFY(text.contains("truncated by QodeAssist")); -} - -void QodeAssistTest::testAcpBackendRejectsAnEmptyTurn() -{ - AcpBackendFixture fixture; - - fixture.sendBlocks({Session::TextBlock{""}}); - - QCOMPARE(fixture.eventsOfType().size(), 1); - QCOMPARE(fixture.eventsOfType().size(), 0); - QVERIFY(fixture.agent == nullptr); -} - -void QodeAssistTest::testAcpBackendSendsAttachmentsWithoutAnyMessageText() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); }; - fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk")); - - fixture.sendBlocks( - {Session::TextBlock{""}, Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}}); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - QCOMPARE(fixture.eventsOfType().size(), 0); - QCOMPARE(fixture.promptBlocksOfType("resource").size(), 1); -} - -namespace { - -QJsonObject writeToolCall() -{ - return QJsonObject{ - {"toolCallId", QStringLiteral("call-1")}, - {"title", QStringLiteral("Write main.cpp")}, - {"kind", QStringLiteral("edit")}, - {"status", QStringLiteral("pending")}}; -} - -QJsonArray writePermissionOptions() -{ - return QJsonArray{ - QJsonObject{ - {"optionId", QStringLiteral("allow")}, - {"name", QStringLiteral("Allow")}, - {"kind", QStringLiteral("allow_once")}}, - QJsonObject{ - {"optionId", QStringLiteral("deny")}, - {"name", QStringLiteral("Deny")}, - {"kind", QStringLiteral("reject_once")}}}; -} - -} // namespace - -void QodeAssistTest::testAcpBackendRaisesAgentPermissionRequests() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setPermissionRequest(writeToolCall(), writePermissionOptions()); - }; - - fixture.send("edit main.cpp"); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::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 QodeAssistTest::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); -} - -namespace { - -class FakeKnowledgeService : public Acp::AgentKnowledgeService -{ -public: - QString start() override - { - ++startCount; - running = true; - return url; - } - - void stop() override - { - ++stopCount; - running = false; - } - - QString serverName() const override { return QStringLiteral("qodeassist"); } - - QString url = QStringLiteral("http://127.0.0.1:54321/mcp"); - int startCount = 0; - int stopCount = 0; - bool running = false; -}; - -} // namespace - -namespace { - -class FakeGatedTool : public ::LLMQore::BaseTool -{ -public: - FakeGatedTool(QString toolId, ::LLMQore::ToolSafety safety, QObject *parent = nullptr) - : ::LLMQore::BaseTool(parent) - , m_id(std::move(toolId)) - , m_safety(safety) - {} - - QString id() const override { return m_id; } - QString displayName() const override { return m_id; } - QString description() const override { return m_id; } - QJsonObject parametersSchema() const override { return {}; } - ::LLMQore::ToolSafety safety() const override { return m_safety; } - - QFuture executeAsync(const QJsonObject &) override - { - ++executions; - QPromise promise; - promise.start(); - promise.addResult(LLMQore::ToolResult::text(QStringLiteral("ran ") + m_id)); - promise.finish(); - return promise.future(); - } - - int executions = 0; - -private: - QString m_id; - ::LLMQore::ToolSafety m_safety; -}; - -} // namespace - -void QodeAssistTest::testAcpBackendSeedsAHandoverSummaryOnce() -{ - AcpBackendFixture fixture; - - fixture.backend.setHandoverSummary("Earlier the user asked about parsing."); - - fixture.send("carry on"); - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::testAcpBackendDropsAHandoverSummaryAfterACancelledTurn() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setHangOnPrompt(true); }; - - fixture.backend.setHandoverSummary("Earlier context."); - - fixture.send("carry on"); - QTRY_COMPARE(fixture.agent->prompts().size(), 1); - - QVERIFY( - fixture.agent->prompts().at(0).at(0).toObject().value("text").toString().contains( - "Earlier context.")); - - fixture.backend.cancel(); - - fixture.configure = [](FakeAcpAgent *) {}; - fixture.send("actually this"); - - QTRY_COMPARE(fixture.agent->prompts().size(), 2); - - const QJsonArray second = fixture.agent->prompts().at(1); - QCOMPARE(second.size(), 1); - QCOMPARE(second.at(0).toObject().value("text").toString(), QString("actually this")); -} - -void QodeAssistTest::testAcpBackendDropsAHandoverSummaryWithTheConversation() -{ - AcpBackendFixture fixture; - - fixture.backend.setHandoverSummary("stale summary"); - fixture.backend.clearToolSession(QString()); - - fixture.send("fresh start"); - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - const QList texts = fixture.promptBlocksOfType("text"); - QCOMPARE(texts.size(), 1); - QCOMPARE(texts.at(0).value("text").toString(), QString("fresh start")); -} - -void QodeAssistTest::testToolSafetyDefaultsToMutating() -{ - FakeGatedTool undeclared("undeclared", ::LLMQore::ToolSafety::Mutating); - QCOMPARE(undeclared.safety(), ::LLMQore::ToolSafety::Mutating); - - QCOMPARE(Tools::ReadFileTool().safety(), ::LLMQore::ToolSafety::ReadOnly); - QCOMPARE(Tools::GetIssuesListTool().safety(), ::LLMQore::ToolSafety::ReadOnly); - QCOMPARE(Tools::ListOpenEditorsTool().safety(), ::LLMQore::ToolSafety::ReadOnly); - - QCOMPARE(Tools::EditFileTool().safety(), ::LLMQore::ToolSafety::Mutating); - QCOMPARE(Tools::CreateNewFileTool().safety(), ::LLMQore::ToolSafety::Mutating); - QCOMPARE(Tools::ExecuteTerminalCommandTool().safety(), ::LLMQore::ToolSafety::Mutating); - QCOMPARE(Tools::BuildProjectTool().safety(), ::LLMQore::ToolSafety::Mutating); -} - -void QodeAssistTest::testToolsManagerWithoutAGateRunsImmediately() -{ - ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); - auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); - manager.addTool(tool); - - manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); - - QTRY_COMPARE(tool->executions, 1); -} - -void QodeAssistTest::testToolsManagerGateCanDeclineAToolCall() -{ - ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); - auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); - manager.addTool(tool); - - QStringList gated; - manager.setExecutionGate( - [&gated]( - const QString &, const QString &, const QString &toolName, const QJsonObject &) { - gated.append(toolName); - QPromise promise; - promise.start(); - promise.addResult(false); - promise.finish(); - return promise.future(); - }); - - QString resultText; - QObject::connect( - &manager, - &::LLMQore::ToolsManager::toolExecutionResult, - &manager, - [&resultText](const QString &, const QString &, const QString &, const QString &result) { - resultText = result; - }); - - manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); - - QTRY_VERIFY(!resultText.isEmpty()); - - QCOMPARE(gated, QStringList{"edit_thing"}); - QCOMPARE(tool->executions, 0); - QVERIFY2(resultText.contains("declined"), qPrintable(resultText)); -} - -void QodeAssistTest::testToolsManagerGateCanAllowAToolCall() -{ - ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); - auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); - manager.addTool(tool); - - manager.setExecutionGate( - [](const QString &, const QString &, const QString &, const QJsonObject &) { - QPromise promise; - promise.start(); - promise.addResult(true); - promise.finish(); - return promise.future(); - }); - - manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); - - QTRY_COMPARE(tool->executions, 1); -} - -void QodeAssistTest::testToolsManagerGateResumesTheQueueAfterADenial() -{ - ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); - auto *denied = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); - auto *allowed = new FakeGatedTool("read_thing", ::LLMQore::ToolSafety::ReadOnly, &manager); - manager.addTool(denied); - manager.addTool(allowed); - - manager.setExecutionGate( - [](const QString &, const QString &, const QString &toolName, const QJsonObject &) { - QPromise promise; - promise.start(); - promise.addResult(toolName != QLatin1String("edit_thing")); - promise.finish(); - return promise.future(); - }); - - bool finished = false; - QObject::connect( - &manager, - &::LLMQore::ToolsManager::toolExecutionComplete, - &manager, - [&finished](const QString &, const QHash &) { - finished = true; - }); - - manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); - manager.executeToolCall("req-1", "call-2", "read_thing", QJsonObject{}); - - QTRY_VERIFY(finished); - QCOMPARE(denied->executions, 0); - QCOMPARE(allowed->executions, 1); -} - -void QodeAssistTest::testKnowledgeServerExposesOnlyReadOnlyTools() -{ - const QStringList exposed = Mcp::AgentKnowledgeServer::toolIds(); - - QCOMPARE( - QSet(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 QodeAssistTest::testKnowledgeServerBindsLoopbackOnAnEphemeralPort() -{ - Mcp::AgentKnowledgeServer server; - - const QString url = server.start(); - QVERIFY(!url.isEmpty()); - QVERIFY(server.runningPort() != 0); - - const QUrl parsed(url); - QVERIFY(QHostAddress(parsed.host()).isLoopback()); - QCOMPARE(parsed.port(), int(server.runningPort())); - - QVERIFY2( - parsed.path().length() > QStringLiteral("/mcp/").length(), - qPrintable(parsed.path())); - - QCOMPARE(server.start(), url); - - server.stop(); - QCOMPARE(server.runningPort(), quint16(0)); - server.stop(); -} - -void QodeAssistTest::testAcpBackendOffersKnowledgeToHttpCapableAgents() -{ - AcpBackendFixture fixture; - FakeKnowledgeService knowledge; - fixture.backend.setKnowledgeService(&knowledge); - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); }; - - fixture.send("what am I looking at"); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - QCOMPARE(knowledge.startCount, 1); - QVERIFY(knowledge.running); - - const QJsonArray offered = fixture.agent->offeredMcpServers(); - QCOMPARE(offered.size(), 1); - QCOMPARE(offered.at(0).toObject().value("name").toString(), QString("qodeassist")); - QCOMPARE(offered.at(0).toObject().value("type").toString(), QString("http")); - QCOMPARE(offered.at(0).toObject().value("url").toString(), knowledge.url); -} - -void QodeAssistTest::testAcpBackendWithholdsKnowledgeFromAgentsWithoutHttpMcp() -{ - AcpBackendFixture fixture; - FakeKnowledgeService knowledge; - fixture.backend.setKnowledgeService(&knowledge); - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(false); }; - - fixture.send("what am I looking at"); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - QCOMPARE(knowledge.startCount, 0); - QVERIFY(!knowledge.running); - QVERIFY(fixture.agent->offeredMcpServers().isEmpty()); -} - -void QodeAssistTest::testAcpBackendStopsTheKnowledgeServerWithTheSession() -{ - AcpBackendFixture fixture; - FakeKnowledgeService knowledge; - fixture.backend.setKnowledgeService(&knowledge); - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); }; - - fixture.send("hello"); - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - QVERIFY(knowledge.running); - - fixture.backend.clearToolSession(QString()); - - QCOMPARE(knowledge.stopCount, 1); - QVERIFY(!knowledge.running); -} - -void QodeAssistTest::testAcpBackendSurvivesAKnowledgeServerThatWillNotStart() -{ - AcpBackendFixture fixture; - FakeKnowledgeService knowledge; - knowledge.url.clear(); - fixture.backend.setKnowledgeService(&knowledge); - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); }; - - fixture.send("hello"); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - QCOMPARE(knowledge.startCount, 1); - QVERIFY(fixture.agent->offeredMcpServers().isEmpty()); - QCOMPARE(fixture.eventsOfType().size(), 0); -} - -void QodeAssistTest::testAcpBackendResumesAPersistedSession() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); }; - - fixture.backend.resumeSession("previous-session"); - QVERIFY(fixture.backend.isResumePending()); - - fixture.send("carry on"); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::testAcpBackendReportsAnUnresumableSession() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setSupportsLoadSession(true); - agent->setLoadSessionError("session expired"); - }; - - QStringList unavailable; - QObject::connect( - &fixture.backend, - &Acp::AcpChatBackend::agentSessionUnavailable, - &fixture.backend, - [&unavailable](const QString &reason) { unavailable.append(reason); }); - - fixture.backend.resumeSession("previous-session"); - fixture.send("carry on"); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::testAcpBackendRefusesToResumeWithoutAgentSupport() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(false); }; - - QStringList unavailable; - QObject::connect( - &fixture.backend, - &Acp::AcpChatBackend::agentSessionUnavailable, - &fixture.backend, - [&unavailable](const QString &reason) { unavailable.append(reason); }); - - fixture.backend.resumeSession("previous-session"); - fixture.send("carry on"); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - QCOMPARE(unavailable.size(), 1); - QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/load")), 0); - QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0); - QVERIFY(fixture.backend.acpSessionId().isEmpty()); -} - -void QodeAssistTest::testAcpBackendStartsFreshAfterAFailedResume() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setSupportsLoadSession(true); - agent->setLoadSessionError("session expired"); - }; - - fixture.backend.resumeSession("previous-session"); - fixture.send("carry on"); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::testAgentBindingKeepsTheResumeTargetBeforeTheFirstTurn() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); }; - - QVERIFY(fixture.backend.bindingSessionId().isEmpty()); - - fixture.backend.resumeSession("sess-from-file"); - QCOMPARE(fixture.backend.bindingSessionId(), QString("sess-from-file")); - QVERIFY(fixture.backend.acpSessionId().isEmpty()); - - fixture.send("carry on"); - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - - QCOMPARE(fixture.backend.acpSessionId(), QString("sess-from-file")); - QCOMPARE(fixture.backend.bindingSessionId(), QString("sess-from-file")); -} - -void QodeAssistTest::testAcpBackendCarriesTheLiveSessionIntoAResume() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); }; - - fixture.send("hello"); - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - QCOMPARE(fixture.backend.acpSessionId(), QString("fake-session")); - - fixture.backend.setChatFilePath("/somewhere/else.json"); - QCOMPARE(fixture.backend.bindingSessionId(), QString("fake-session")); - - fixture.backend.clearToolSession(QString()); - QVERIFY(fixture.backend.bindingSessionId().isEmpty()); -} - -void QodeAssistTest::testAcpBackendEstablishesTheSessionOnlyOnce() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { agent->setHangOnNewSession(true); }; - - fixture.send("first"); - QTRY_COMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1); - - fixture.backend.cancel(); - fixture.send("second"); - QTest::qWait(50); - - QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1); - QCOMPARE(fixture.agent->prompts().size(), 0); -} - -void QodeAssistTest::testAgentBindingRejectsMalformedRecords() -{ - const auto rejected = [](const QJsonValue &value) { - QString error; - const Acp::AgentBinding binding = Acp::AgentBinding::fromJson(value, &error); - return binding.isEmpty() && !error.isEmpty(); - }; - - QVERIFY(rejected(QJsonValue(QStringLiteral("claude")))); - QVERIFY(rejected(QJsonValue(42))); - QVERIFY(rejected(QJsonObject{{"agentId", 7}})); - QVERIFY(rejected(QJsonObject{{"agentId", "claude"}, {"sessionId", QJsonArray{}}})); - QVERIFY(rejected(QJsonObject{{"sessionId", "orphan"}})); - QVERIFY(rejected(QJsonObject{{"agentId", QString(4096, QChar('a'))}})); - QVERIFY(rejected(QJsonObject{{"agentId", QStringLiteral("clau‮de")}})); - - QString error; - QVERIFY(Acp::AgentBinding::fromJson(QJsonValue(), &error).isEmpty()); - QVERIFY(error.isEmpty()); - - const Acp::AgentBinding good = Acp::AgentBinding::fromJson( - QJsonObject{{"agentId", "claude"}, {"sessionId", "sess-1"}}, &error); - QCOMPARE(good, (Acp::AgentBinding{"claude", "sess-1"})); - QVERIFY(error.isEmpty()); -} - -void QodeAssistTest::testMalformedAgentBindingLeavesTheChatUnbound() -{ - QTemporaryDir dir; - QVERIFY(dir.isValid()); - - const QString filePath = dir.filePath("broken_agent.json"); - - Session::Message user; - user.role = Session::MessageRole::User; - user.blocks = {Session::TextBlock{"hello"}}; - - Session::ConversationHistory history; - history.append(user); - - QVERIFY(Chat::ChatFileStore::saveToFile(history, Acp::AgentBinding{}, filePath).success); - - QFile file(filePath); - QVERIFY(file.open(QIODevice::ReadWrite)); - QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); - root["agent"] = QStringLiteral("claude"); - file.resize(0); - file.write(QJsonDocument(root).toJson()); - file.close(); - - Session::ConversationHistory reloaded; - Acp::AgentBinding binding; - const Chat::SerializationResult result - = Chat::ChatFileStore::loadFromFile(reloaded, binding, filePath); - - QVERIFY(result.success); - QVERIFY(binding.isEmpty()); - QVERIFY(!result.warningMessage.isEmpty()); -} - -void QodeAssistTest::testAgentBindingRoundTripsThroughTheChatFile() -{ - QTemporaryDir dir; - QVERIFY(dir.isValid()); - - const QString filePath = dir.filePath("agent_chat.json"); - - Session::Message user; - user.role = Session::MessageRole::User; - user.blocks = {Session::TextBlock{"hello agent"}}; - - Session::ConversationHistory history; - history.append(user); - - const Acp::AgentBinding binding{"claude-code-acp", "sess-42"}; - QVERIFY(Chat::ChatFileStore::saveToFile(history, binding, filePath).success); - - Session::ConversationHistory reloaded; - Acp::AgentBinding reloadedBinding; - QVERIFY(Chat::ChatFileStore::loadFromFile(reloaded, reloadedBinding, filePath).success); - - QCOMPARE(reloaded, history); - QCOMPARE(reloadedBinding, binding); -} - -void QodeAssistTest::testChatFileWithoutAnAgentLoadsUnbound() -{ - QTemporaryDir dir; - QVERIFY(dir.isValid()); - - const QString filePath = dir.filePath("llm_chat.json"); - - Session::Message user; - user.role = Session::MessageRole::User; - user.blocks = {Session::TextBlock{"hello"}}; - - Session::ConversationHistory history; - history.append(user); - - QVERIFY(Chat::ChatFileStore::saveToFile(history, Acp::AgentBinding{}, filePath).success); - - QFile file(filePath); - QVERIFY(file.open(QIODevice::ReadOnly)); - const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); - QVERIFY(!root.contains("agent")); - file.close(); - - Session::ConversationHistory reloaded; - Acp::AgentBinding reloadedBinding; - QVERIFY(Chat::ChatFileStore::loadFromFile(reloaded, reloadedBinding, filePath).success); - QVERIFY(reloadedBinding.isEmpty()); -} - -void QodeAssistTest::testAcpBackendMapsToolCallLifecycle() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setToolCallUpdates( - {QJsonObject{ - {"toolCallId", QStringLiteral("call-1")}, - {"title", QStringLiteral("Edit main.cpp")}, - {"kind", QStringLiteral("edit")}, - {"status", QStringLiteral("pending")}}, - QJsonObject{ - {"toolCallId", QStringLiteral("call-1")}, - {"status", QStringLiteral("completed")}, - {"locations", - QJsonArray{QJsonObject{{"path", QStringLiteral("/p/main.cpp")}, {"line", 12}}}}, - {"content", - QJsonArray{ - QJsonObject{ - {"type", QStringLiteral("diff")}, - {"path", QStringLiteral("/p/main.cpp")}, - {"oldText", QStringLiteral("int a;")}, - {"newText", QStringLiteral("int b;")}}}}}}); - }; - - fixture.send("edit it"); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::testAcpBackendMapsPlanUpdates() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setPlanUpdates( - {QJsonArray{ - QJsonObject{ - {"content", QStringLiteral("Read the file")}, - {"priority", QStringLiteral("high")}, - {"status", QStringLiteral("in_progress")}}, - QJsonObject{ - {"content", QStringLiteral("Apply the fix")}, - {"priority", QStringLiteral("medium")}, - {"status", QStringLiteral("pending")}}}, - QJsonArray{ - QJsonObject{ - {"content", QStringLiteral("Read the file")}, - {"priority", QStringLiteral("high")}, - {"status", QStringLiteral("completed")}}, - QJsonObject{ - {"content", QStringLiteral("Apply the fix")}, - {"priority", QStringLiteral("medium")}, - {"status", QStringLiteral("in_progress")}}}}); - }; - - fixture.send("fix it"); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::testAcpBackendReportsUsageFromThePromptResult() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setPromptUsage( - QJsonObject{ - {"cachedReadTokens", 23119}, - {"cachedWriteTokens", 12271}, - {"inputTokens", 2}, - {"outputTokens", 13}, - {"totalTokens", 35405}}); - }; - - fixture.send("hi"); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::testAcpBackendIgnoresTheCumulativeContextGauge() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setContextGauge( - QJsonObject{ - {"size", 967000}, - {"used", 35395}, - {"cost", QJsonObject{{"amount", 0.0807}, {"currency", QStringLiteral("USD")}}}}); - }; - - fixture.send("hi"); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - QCOMPARE(fixture.eventsOfType().size(), 0); -} - -void QodeAssistTest::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 QodeAssistTest::testAcpBackendDeclinesAmbiguousPermissionOptions() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setPermissionRequest( - writeToolCall(), - QJsonArray{ - QJsonObject{ - {"optionId", QStringLiteral("x")}, - {"name", QStringLiteral("Yes, and don't ask again")}, - {"kind", QStringLiteral("allow_always")}}, - QJsonObject{ - {"optionId", QStringLiteral("x")}, - {"name", QStringLiteral("No")}, - {"kind", QStringLiteral("reject_once")}}}); - }; - - fixture.send("edit main.cpp"); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::testAcpBackendClampsOversizedPermissionText() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - QJsonObject toolCall = writeToolCall(); - toolCall["title"] = QString(5000, QChar('A')); - agent->setPermissionRequest(toolCall, writePermissionOptions()); - }; - - fixture.send("edit main.cpp"); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::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 QodeAssistTest::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); -} - -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 QodeAssistTest::testTurnLedgerTracksTheActiveTurn() -{ - Session::TurnLedger ledger; - - QVERIFY(!ledger.hasActiveTurn()); - QVERIFY(!ledger.isActiveTurn(QString())); - QVERIFY(!ledger.isActiveTurn("t1")); - - QCOMPARE(ledger.beginTurn("t1"), QString("t1")); - QVERIFY(ledger.hasActiveTurn()); - QVERIFY(ledger.isActiveTurn("t1")); - QVERIFY(!ledger.isActiveTurn("t2")); - QVERIFY(!ledger.isActiveTurn(QString())); - - ledger.endTurn(); - QVERIFY(!ledger.hasActiveTurn()); - QVERIFY(!ledger.isActiveTurn("t1")); -} - -void QodeAssistTest::testTurnLedgerDrainsPermissionsOnTurnEnd() -{ - Session::TurnLedger ledger; - ledger.beginTurn("t1"); - - QStringList resolved; - int cancelled = 0; - - const QString first = ledger.registerPermission( - [&resolved](const QString &optionId) { resolved.append(optionId); }, - [&cancelled] { ++cancelled; }); - const QString second = ledger.registerPermission( - [&resolved](const QString &optionId) { resolved.append(optionId); }, - [&cancelled] { ++cancelled; }); - - QVERIFY(first.startsWith("perm-")); - QVERIFY(second.startsWith("perm-")); - QVERIFY(first != second); - QCOMPARE(ledger.pendingPermissionCount(), 2); - - QVERIFY(ledger.resolvePermission(first, "allow_once")); - QCOMPARE(resolved, QStringList{"allow_once"}); - QVERIFY(!ledger.resolvePermission(first, "allow_once")); - QVERIFY(!ledger.hasPendingPermission(first)); - QVERIFY(ledger.hasPendingPermission(second)); - - const QStringList drained = ledger.endTurn(); - QCOMPARE(drained, QStringList{second}); - QCOMPARE(cancelled, 1); - QCOMPARE(ledger.pendingPermissionCount(), 0); - QVERIFY(!ledger.resolvePermission(second, "allow_once")); - QCOMPARE(resolved, QStringList{"allow_once"}); -} - -void QodeAssistTest::testLlmBackendStreamsThroughAFakeClient() -{ - LlmBackendFixture fixture; - - const QString turnId = fixture.send(); - QCOMPARE(turnId, QString("fake-req-1")); - QVERIFY(fixture.provider.fakeClient()->lastPayload.contains("model")); - QVERIFY(fixture.provider.fakeClient()->lastPayload.contains("stream")); - - emit fixture.provider.fakeClient()->chunkReceived(turnId, "hel"); - emit fixture.provider.fakeClient()->chunkReceived(turnId, "lo"); - emit fixture.provider.fakeClient()->requestCompleted(turnId, "hello"); - - const auto deltas = fixture.eventsOfType(); - 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 QodeAssistTest::testRenderHistoryKeepsToolExchangePairs() -{ - LlmBackendFixture fixture; - - Session::Message assistant; - assistant.role = Session::MessageRole::Assistant; - assistant.id = "r0"; - assistant.blocks - = {Session::ToolCallBlock{ - "t1", - "read_file", - QJsonObject{{"path", "main.cpp"}}, - "int main() {}", - {}, - "completed"}, - Session::ToolCallBlock{ - "call-9", - "Edit main.cpp", - QJsonObject{}, - "done", - "edit", - "completed", - QJsonObject{}, - true}, - Session::TextBlock{"the answer"}}; - fixture.history.append(assistant); - - fixture.send(); - - QVERIFY(fixture.provider.lastContext.history.has_value()); - const QVector &messages = *fixture.provider.lastContext.history; - - QCOMPARE(messages.size(), 4); - QCOMPARE(messages.at(0).role, QString("user")); - QCOMPARE(messages.at(1).role, QString("assistant")); - QCOMPARE(messages.at(1).toolCalls.size(), 1); - QCOMPARE(messages.at(1).toolCalls.at(0).id, QString("t1")); - QCOMPARE(messages.at(1).toolCalls.at(0).name, QString("read_file")); - QCOMPARE( - messages.at(1).toolCalls.at(0).arguments, (QJsonObject{{"path", "main.cpp"}})); - QCOMPARE(messages.at(2).role, QString("tool")); - QCOMPARE(messages.at(2).toolCallId, QString("t1")); - QCOMPARE(messages.at(2).content, QString("int main() {}")); - QCOMPARE(messages.at(3).role, QString("assistant")); - QCOMPARE(messages.at(3).content, QString("the answer")); -} - -void QodeAssistTest::testLlmBackendIgnoresEventsFromOtherRequests() -{ - LlmBackendFixture fixture; - - const QString turnId = fixture.send(); - QVERIFY(!turnId.isEmpty()); - - emit fixture.provider.fakeClient()->chunkReceived("stale-req", "ignored"); - QCOMPARE(fixture.eventsOfType().size(), 0); - - fixture.backend.cancel(); - - emit fixture.provider.fakeClient()->chunkReceived(turnId, "late"); - QCOMPARE(fixture.eventsOfType().size(), 0); - QCOMPARE(fixture.eventsOfType().size(), 0); -} - -void QodeAssistTest::testLlmBackendPermissionRoundTrip() -{ - LlmBackendFixture fixture; - auto *tool = new FakeMutatingTool(&fixture.backend); - fixture.provider.toolsManager()->addTool(tool); - - const QString turnId = fixture.send(); - - fixture.provider.toolsManager()->executeToolCall(turnId, "t1", "fake_write", {}); - - QTRY_COMPARE(fixture.eventsOfType().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 QodeAssistTest::testLlmBackendDrainsPermissionsWhenTheTurnEnds() -{ - LlmBackendFixture fixture; - auto *tool = new FakeMutatingTool(&fixture.backend); - fixture.provider.toolsManager()->addTool(tool); - - QString turnId = fixture.send(); - fixture.provider.toolsManager()->executeToolCall(turnId, "t1", "fake_write", {}); - QTRY_COMPARE(fixture.eventsOfType().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().size(), 1); - - fixture.events.clear(); - turnId = fixture.send(); - fixture.provider.toolsManager()->executeToolCall(turnId, "t3", "fake_write", {}); - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - requestId = fixture.eventsOfType().at(0).requestId; - - fixture.backend.cancel(); - - resolutions = fixture.eventsOfType(); - QCOMPARE(resolutions.size(), 1); - QVERIFY(resolutions.at(0).cancelled); - QVERIFY(!fixture.backend.respondPermission(requestId, "allow_once")); - QCOMPARE(tool->executions, 0); -} - -namespace { - -Acp::AgentDefinition launchableAgent(const QString &id) -{ - Acp::AgentDefinition agent; - agent.id = id; - agent.name = id; - agent.distribution.kind = Acp::AgentDistributionKind::Command; - agent.distribution.command = "/bin/true"; - return agent; -} - -class FakeConversationOps : public Chat::IConversationPort -{ -public: - QString boundAgentId() const override { return boundId; } - bool conversationStarted() const override { return started; } - bool transcriptEmpty() const override { return transcriptIsEmpty; } - Acp::AgentBinding agentBinding() const override { return liveBinding; } - - void bindAgent(const Acp::AgentDefinition &agent) override - { - boundId = agent.id; - calls.append("bindAgent:" + agent.id); - } - - void bindLlm() override - { - boundId.clear(); - calls.append("bindLlm"); - } - - void clearConversation() override - { - started = false; - calls.append("clear"); - } - - void resumeAgentSession(const QString &sessionId) override - { - calls.append("resume:" + sessionId); - } - - void startFreshAgentSession() override { calls.append("fresh"); } - - void startFreshAgentSession(const QString &handoverSummary) override - { - calls.append("freshWithSummary:" + handoverSummary); - } - - void releaseAgentSession() override { calls.append("release"); } - - QString boundId; - bool started = false; - bool transcriptIsEmpty = true; - Acp::AgentBinding liveBinding; - QStringList calls; -}; - -class FakeAgentCatalog : public Chat::IAgentCatalogPort -{ -public: - std::optional 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 QodeAssistTest::testCoordinatorSwitchConfirmAndCancelRoundTrip() -{ - CoordinatorFixture fixture; - - fixture.coordinator.chooseAgent(launchableAgent("agent-a")); - QVERIFY(!fixture.coordinator.switchPending()); - QCOMPARE(fixture.ops.calls, QStringList{"bindAgent:agent-a"}); - - fixture.ops.started = true; - fixture.ops.calls.clear(); - - fixture.coordinator.chooseLlm(); - QVERIFY(fixture.coordinator.switchPending()); - QCOMPARE(fixture.confirmations.size(), 1); - QVERIFY(fixture.ops.calls.isEmpty()); - - fixture.coordinator.cancelSwitch(); - QVERIFY(!fixture.coordinator.switchPending()); - QVERIFY(fixture.ops.calls.isEmpty()); - QCOMPARE(fixture.ops.boundId, QString("agent-a")); - - fixture.coordinator.chooseAgent(launchableAgent("agent-b")); - QVERIFY(fixture.coordinator.switchPending()); - QCOMPARE(fixture.confirmations.size(), 2); - - fixture.coordinator.confirmSwitch(); - QVERIFY(!fixture.coordinator.switchPending()); - QCOMPARE(fixture.ops.calls, (QStringList{"clear", "bindAgent:agent-b"})); - QVERIFY(!fixture.ops.started); - - fixture.coordinator.confirmSwitch(); - QCOMPARE(fixture.ops.calls, (QStringList{"clear", "bindAgent:agent-b"})); -} - -void QodeAssistTest::testCoordinatorQuarantinesAndRestoresAgentBindings() -{ - CoordinatorFixture fixture; - const Acp::AgentBinding binding{"ghost-agent", "sess-1"}; - - fixture.coordinator.restoreAgentBinding(binding); - - QVERIFY(fixture.coordinator.readOnly()); - QVERIFY(!fixture.coordinator.canStartFreshSession()); - QCOMPARE(fixture.coordinator.bindingForSave(), binding); - QCOMPARE(fixture.ops.calls, (QStringList{"release", "bindLlm"})); - - fixture.ops.calls.clear(); - fixture.catalog.agents.insert("ghost-agent", launchableAgent("ghost-agent")); - - fixture.coordinator.restoreAgentBinding(binding); - - QVERIFY(!fixture.coordinator.readOnly()); - QCOMPARE(fixture.ops.calls, (QStringList{"release", "bindAgent:ghost-agent", "resume:sess-1"})); - fixture.ops.liveBinding = Acp::AgentBinding{"ghost-agent", "sess-1"}; - QCOMPARE(fixture.coordinator.bindingForSave(), fixture.ops.liveBinding); - - fixture.ops.calls.clear(); - fixture.ops.started = true; - - fixture.coordinator.restoreAgentBinding(Acp::AgentBinding{"ghost-agent", QString()}); - - QVERIFY(fixture.coordinator.readOnly()); - QVERIFY(fixture.coordinator.canStartFreshSession()); - QCOMPARE(fixture.ops.calls, (QStringList{"release", "bindAgent:ghost-agent"})); - - fixture.coordinator.startFreshSession(); - QVERIFY(!fixture.coordinator.readOnly()); - QVERIFY(fixture.ops.calls.contains("fresh")); -} - -void QodeAssistTest::testCoordinatorRefusesIntentsWhileReadOnly() -{ - CoordinatorFixture fixture; - fixture.coordinator.agentSessionUnavailable("gone"); - QVERIFY(fixture.coordinator.readOnly()); - - fixture.coordinator.requestSend("hi", {}); - QCOMPARE(fixture.send.dispatched.size(), 0); - QCOMPARE(fixture.errors.size(), 1); - - QVERIFY(fixture.coordinator.refuseWhileReadOnly()); - QCOMPARE(fixture.errors.size(), 2); - - fixture.coordinator.compressionSettled(); - QCOMPARE(fixture.send.dispatched.size(), 0); - - fixture.coordinator.startFreshSession(); - QVERIFY(!fixture.coordinator.readOnly()); - QVERIFY(!fixture.coordinator.refuseWhileReadOnly()); - - fixture.coordinator.requestSend("hi again", {}); - QCOMPARE(fixture.send.dispatched, QStringList{"hi again"}); -} - -void QodeAssistTest::testCoordinatorDefersTheSendForAutoCompress() -{ - CoordinatorFixture fixture; - fixture.send.autoCompress = true; - fixture.send.threshold = 100; - fixture.send.tokens = 150; - - fixture.coordinator.requestSend("big message", {}); - QCOMPARE(fixture.send.dispatched.size(), 0); - QCOMPARE(fixture.compression.compressionStarts, 1); - QVERIFY(fixture.coordinator.hasDeferredSend()); - - fixture.coordinator.requestSend("while compressing", {}); - QCOMPARE(fixture.send.dispatched, QStringList{"while compressing"}); - QCOMPARE(fixture.compression.compressionStarts, 1); - - fixture.coordinator.compressionSettled(); - QCOMPARE(fixture.send.dispatched, (QStringList{"while compressing", "big message"})); - QVERIFY(!fixture.coordinator.hasDeferredSend()); - - fixture.send.tokens = 50; - fixture.coordinator.requestSend("small", {}); - QCOMPARE(fixture.send.dispatched.size(), 3); - QCOMPARE(fixture.compression.compressionStarts, 1); - - fixture.send.tokens = 150; - fixture.ops.boundId = "agent-a"; - fixture.coordinator.requestSend("agent bound", {}); - QCOMPARE(fixture.send.dispatched.size(), 4); - QCOMPARE(fixture.compression.compressionStarts, 1); - - fixture.ops.boundId.clear(); - fixture.send.chatFileAvailable = false; - fixture.coordinator.requestSend("no chat file", {}); - QCOMPARE(fixture.send.dispatched.size(), 5); - QCOMPARE(fixture.compression.compressionStarts, 1); -} - -void QodeAssistTest::testCoordinatorGatesTheSummaryHandover() -{ - CoordinatorFixture fixture; - fixture.coordinator.agentSessionUnavailable("gone"); - fixture.ops.transcriptIsEmpty = false; - - QVERIFY(fixture.coordinator.canHandOverSummary()); - fixture.coordinator.handOverSummary(); - QCOMPARE(fixture.compression.summaryStarts, 1); - - fixture.coordinator.summaryProduced("the summary"); - QVERIFY(fixture.ops.calls.contains("freshWithSummary:the summary")); - QVERIFY(!fixture.coordinator.readOnly()); - - fixture.coordinator.agentSessionUnavailable("gone again"); - fixture.compression.configurationIssue = "no chat configuration is assigned"; - QVERIFY(!fixture.coordinator.canHandOverSummary()); - QVERIFY(fixture.coordinator.summaryHandoverTooltip().contains("cannot be produced")); - fixture.coordinator.handOverSummary(); - QCOMPARE(fixture.compression.summaryStarts, 1); - - fixture.compression.configurationIssue.clear(); - fixture.ops.transcriptIsEmpty = true; - QVERIFY(!fixture.coordinator.canHandOverSummary()); - - fixture.ops.transcriptIsEmpty = false; - fixture.compression.running = true; - QVERIFY(fixture.coordinator.canHandOverSummary()); - fixture.coordinator.handOverSummary(); - QCOMPARE(fixture.compression.summaryStarts, 1); - QVERIFY(!fixture.errors.isEmpty()); -} - -void QodeAssistTest::testCoordinatorShrinkContextRoutesByBackendKind() -{ - CoordinatorFixture fixture; - fixture.ops.transcriptIsEmpty = false; - - fixture.coordinator.shrinkContext(); - QCOMPARE(fixture.compression.compressionStarts, 1); - QCOMPARE(fixture.compression.summaryStarts, 0); - - fixture.compression.configurationIssue = "no chat configuration is assigned"; - QVERIFY(!fixture.coordinator.canShrinkContext()); - fixture.coordinator.shrinkContext(); - QCOMPARE(fixture.compression.compressionStarts, 1); - QCOMPARE(fixture.errors.size(), 1); - QVERIFY(fixture.coordinator.shrinkContextTooltip().contains("Unavailable")); - fixture.compression.configurationIssue.clear(); - - fixture.ops.boundId = "agent-a"; - QVERIFY(fixture.coordinator.canShrinkContext()); - fixture.coordinator.shrinkContext(); - QCOMPARE(fixture.compression.compressionStarts, 1); - QCOMPARE(fixture.compression.summaryStarts, 1); - - fixture.ops.transcriptIsEmpty = true; - QVERIFY(!fixture.coordinator.canShrinkContext()); - fixture.coordinator.shrinkContext(); - QCOMPARE(fixture.compression.summaryStarts, 1); - QVERIFY(fixture.coordinator.shrinkContextTooltip().contains("nothing to summarise")); - - fixture.ops.transcriptIsEmpty = false; - fixture.compression.configurationIssue = "no chat configuration is assigned"; - QVERIFY(!fixture.coordinator.canShrinkContext()); - fixture.coordinator.shrinkContext(); - QCOMPARE(fixture.compression.summaryStarts, 1); - fixture.compression.configurationIssue.clear(); - - fixture.compression.running = true; - const int errorsBeforeBusy = fixture.errors.size(); - fixture.coordinator.shrinkContext(); - QCOMPARE(fixture.compression.summaryStarts, 1); - QCOMPARE(fixture.errors.size(), errorsBeforeBusy + 1); - - const int dispatchedBeforeBusySend = fixture.send.dispatched.size(); - fixture.coordinator.requestSend("while summarising", {}); - QCOMPARE(fixture.send.dispatched.size(), dispatchedBeforeBusySend); - QCOMPARE(fixture.errors.size(), errorsBeforeBusy + 2); - fixture.compression.running = false; - - fixture.coordinator.agentSessionUnavailable("gone"); - QVERIFY(!fixture.coordinator.canShrinkContext()); - const int errorsBeforeReadOnly = fixture.errors.size(); - fixture.coordinator.shrinkContext(); - QCOMPARE(fixture.errors.size(), errorsBeforeReadOnly + 1); - QCOMPARE(fixture.compression.summaryStarts, 1); - QCOMPARE(fixture.compression.compressionStarts, 1); -} - -void QodeAssistTest::testAcpBackendTracksAgentSlashCommands() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setAvailableCommandUpdates( - {QJsonArray{ - QJsonObject{ - {"name", QStringLiteral("help")}, - {"description", QStringLiteral("Show help")}, - {"input", QJsonObject{{"hint", QStringLiteral("topic")}}}}, - QJsonObject{ - {"name", QStringLiteral("review")}, - {"description", QStringLiteral("Review\nthe diff")}}}, - QJsonArray{ - QJsonObject{ - {"name", QStringLiteral("help")}, - {"description", QStringLiteral("Show help")}}}}); - }; - - int changes = 0; - QObject::connect( - &fixture.backend, - &Acp::AcpChatBackend::availableCommandsChanged, - &fixture.backend, - [&changes] { ++changes; }); - - fixture.send("hi"); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - QTRY_COMPARE(fixture.backend.availableCommands().size(), 1); - QCOMPARE(fixture.backend.availableCommands().at(0).name, QString("help")); - QCOMPARE(changes, 2); - - fixture.backend.clearToolSession("chat.json"); - QCOMPARE(fixture.backend.availableCommands().size(), 0); - QCOMPARE(changes, 3); -} - -void QodeAssistTest::testEarlyAgentCommandsSurviveEstablishment() -{ - AcpBackendFixture fixture; - fixture.configure = [](FakeAcpAgent *agent) { - agent->setCommandsBeforeSessionReply( - QJsonArray{QJsonObject{ - {"name", QStringLiteral("help")}, - {"description", QStringLiteral("Show help")}}}); - }; - - fixture.send("hi"); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - QCOMPARE(fixture.backend.availableCommands().size(), 1); - QCOMPARE(fixture.backend.availableCommands().at(0).name, QString("help")); -} - -void QodeAssistTest::testAgentCommandInvocationSendsPlainPrompt() -{ - AcpBackendFixture fixture; - - fixture.send("/review src/main.cpp"); - - QTRY_COMPARE(fixture.eventsOfType().size(), 1); - QCOMPARE(fixture.agent->prompts().size(), 1); - - const QJsonArray prompt = fixture.agent->prompts().at(0); - QCOMPARE(prompt.size(), 1); - QCOMPARE(prompt.at(0).toObject().value("type").toString(), QString("text")); - QCOMPARE( - prompt.at(0).toObject().value("text").toString(), QString("/review src/main.cpp")); -} - -void QodeAssistTest::testFileMentionSelectionFollowsChatToolsSetting() -{ - auto &enableChatTools = Settings::chatAssistantSettings().enableChatTools; - const bool originalValue = enableChatTools(); - const auto restore = qScopeGuard( - [&] { enableChatTools.setValue(originalValue, Utils::BaseAspect::BeQuiet); }); - - Chat::FileMentionItem item; - QStringList attachRequests; - connect( - &item, - &Chat::FileMentionItem::fileAttachRequested, - this, - [&attachRequests](const QStringList &paths) { attachRequests += paths; }); - - enableChatTools.setValue(true, Utils::BaseAspect::BeQuiet); - QVariantMap result = item.handleFileSelection("/proj/src/main.cpp", "src/main.cpp", "proj", "main"); - QCOMPARE(result.value("mode").toString(), QString("mention")); - QCOMPARE(result.value("mentionText").toString(), QString("@main.cpp ")); - QVERIFY(attachRequests.isEmpty()); - - enableChatTools.setValue(false, Utils::BaseAspect::BeQuiet); - result = item.handleFileSelection("/proj/src/main.cpp", "src/main.cpp", "proj", "main"); - QCOMPARE(result.value("mode").toString(), QString("attach")); - QCOMPARE(attachRequests, QStringList{"/proj/src/main.cpp"}); -} - -void QodeAssistTest::testChatModelExposesSessionRowsDirectly() -{ - Chat::ChatModel model; - - Session::MessageRow tool; - tool.kind = Session::RowKind::AgentTool; - tool.id = "t1"; - tool.toolKind = "edit"; - tool.toolStatus = "completed"; - tool.toolDetails = QJsonObject{{"diffs", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}}; - tool.usage = Session::Usage{10, 5, 0, 0}; - - model.resetMessages({tool}); - - QCOMPARE(model.rowCount(), 1); - QCOMPARE( - model.data(model.index(0), Chat::ChatModel::RoleType).value(), - Chat::ChatModel::ChatRole::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")); -} - -void QodeAssistTest::testBlockCodecRejectsForgedMarkers() -{ - const QJsonObject payload{{"key", "value"}}; - - for (const QLatin1StringView marker : Session::knownPayloadMarkers()) { - const QString encoded = Session::encodeMarkerPayload(marker, payload); - QVERIFY(encoded.startsWith(marker)); - QVERIFY(Session::hasPayloadMarker(marker, encoded)); - - const auto decoded = Session::decodeMarkerPayload(marker, encoded); - QVERIFY(decoded.has_value()); - QCOMPARE(*decoded, payload); - - QVERIFY(!Session::hasPayloadMarker(marker, QStringLiteral(" ") + encoded)); - QVERIFY(!Session::decodeMarkerPayload(marker, QStringLiteral(" ") + encoded)); - QVERIFY(!Session::decodeMarkerPayload(marker, QStringLiteral("echoed: ") + encoded)); - QVERIFY(!Session::decodeMarkerPayload(marker, QString(marker))); - QVERIFY(!Session::decodeMarkerPayload(marker, marker + QStringLiteral("not json"))); - QVERIFY(!Session::decodeMarkerPayload(marker, marker + QStringLiteral("[1, 2]"))); - } -} - -void QodeAssistTest::testFileEditParsingRequiresTheMarkerAtTheStart() -{ - const QString encoded = Session::encodeFileEditPayload(QJsonObject{{"edit_id", "e1"}}); - - QVERIFY(Session::isFileEditPayload(encoded)); - const auto parsed = Session::parseFileEditPayload(encoded); - QVERIFY(parsed.has_value()); - QCOMPARE(parsed->value("edit_id").toString(), QString("e1")); - - const QString echoed = QStringLiteral("tool result quoting ") + encoded; - QVERIFY(!Session::isFileEditPayload(echoed)); - QVERIFY(!Session::parseFileEditPayload(echoed)); -} - -void QodeAssistTest::testPermissionOptionsCarryAllowsAcrossTheSeam() -{ - Session::PermissionBlock block; - block.requestId = "p1"; - block.title = "Edit main.cpp"; - block.options - = {Session::PermissionOption{"a", "Allow", "allow_once"}, - Session::PermissionOption{"b", "Always", "allow_always"}, - Session::PermissionOption{"c", "Deny", "reject_once"}}; - - const QString encoded = Session::encodePermissionBlock(block); - const auto payload = Session::decodeMarkerPayload(Session::permissionPayloadMarker, encoded); - QVERIFY(payload.has_value()); - - const QJsonArray options = payload->value("options").toArray(); - QCOMPARE(options.size(), 3); - QCOMPARE(options.at(0).toObject().value("allows").toBool(), true); - QCOMPARE(options.at(1).toObject().value("allows").toBool(), true); - QCOMPARE(options.at(2).toObject().value("allows").toBool(), false); - - const auto decoded = Session::decodePermissionBlock(encoded); - QVERIFY(decoded.has_value()); - QCOMPARE(decoded->options, block.options); -} - -void QodeAssistTest::testChatFileStoreRoundTripsStoredContent() -{ - QTemporaryDir dir; - QVERIFY(dir.isValid()); - - const QString chatFilePath = dir.filePath("chat.json"); - const QByteArray original = QByteArrayLiteral("int main() { return 0; }\n"); - - QString storedPath; - QVERIFY(Chat::ChatFileStore::saveContentToStorage( - chatFilePath, "main.cpp", QString::fromLatin1(original.toBase64()), storedPath)); - QVERIFY(!storedPath.isEmpty()); - - QCOMPARE(Chat::ChatFileStore::loadRawContentFromStorage(chatFilePath, storedPath), original); - QCOMPARE( - Chat::ChatFileStore::loadContentFromStorage(chatFilePath, storedPath), - QString::fromLatin1(original.toBase64())); - - const QString contentFolder = Chat::ChatFileStore::getChatContentFolder(chatFilePath); - QVERIFY(QFileInfo(QDir(contentFolder).filePath(storedPath)).exists()); -} - -void QodeAssistTest::testLegacyChatFileLoadsThroughTheFileStore() -{ - QTemporaryDir dir; - QVERIFY(dir.isValid()); - - QJsonObject root; - root["version"] = "0.2"; - root["messages"] = QJsonArray{ - QJsonObject{{"role", 1}, {"content", "explain this"}, {"id", "u1"}}, - QJsonObject{{"role", 2}, {"content", "here is the answer"}, {"id", "r1"}}}; - - const QString filePath = dir.filePath("legacy.json"); - QFile file(filePath); - QVERIFY(file.open(QIODevice::WriteOnly)); - file.write(QJsonDocument(root).toJson()); - file.close(); - - Session::ConversationHistory history; - Acp::AgentBinding binding; - const Chat::SerializationResult result - = Chat::ChatFileStore::loadFromFile(history, binding, filePath); - - QVERIFY(result.success); - QVERIFY(result.warningMessage.isEmpty()); - QVERIFY(binding.isEmpty()); - QCOMPARE(history.size(), 2); - QCOMPARE(history.at(0).role, Session::MessageRole::User); - QCOMPARE( - std::get(history.at(0).blocks.at(0)), - (Session::TextBlock{"explain this"})); - QCOMPARE(history.at(1).role, Session::MessageRole::Assistant); -} - -void QodeAssistTest::testPickerObservesTheSharedAgentCatalog() -{ - Acp::AgentCatalogStore store; - Chat::ChatConfigurationController controller; - - QSignalSpy listChanged( - &controller, &Chat::ChatConfigurationController::availableConfigurationsChanged); - - controller.setAgentCatalog(&store); - QVERIFY(listChanged.count() >= 1); - QVERIFY(!controller.availableConfigurations().isEmpty()); - - const int before = listChanged.count(); - emit store.catalogChanged(); - QCOMPARE(listChanged.count(), before + 1); - - controller.setAgentCatalog(&store); - QCOMPARE(listChanged.count(), before + 1); -} - -} // namespace QodeAssist diff --git a/tests/QodeAssistTest.hpp b/tests/QodeAssistTest.hpp deleted file mode 100644 index c10d041..0000000 --- a/tests/QodeAssistTest.hpp +++ /dev/null @@ -1,263 +0,0 @@ -// 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" - -QT_BEGIN_NAMESPACE -class QTextDocument; -QT_END_NAMESPACE - -namespace QodeAssist { - -class QodeAssistTest final : public QObject -{ - Q_OBJECT - -private slots: - void testProcessTextEmpty(); - void testProcessTextCommentsAroundCodeBlock(); - void testProcessTextWithPlainCodeBlockNoNewline(); - void testProcessTextWithPlainCodeBlockWithNewline(); - void testProcessTextNoCommentsWithLanguageCodeBlock(); - void testProcessTextNoCommentsWithPlainCodeBlockNoNewline(); - void testProcessTextNoCommentsWithPlainCodeBlockWithNewline(); - void testProcessTextWithMultipleCodeBlocksDifferentLanguages(); - void testProcessTextWithMultipleCodeBlocksSameLanguage(); - void testProcessTextWithMultiplePlainCodeBlocksWithNewline(); - void testProcessTextWithMultiplePlainCodeBlocksWithoutNewline(); - void testProcessTextWithEmptyLines(); - void testProcessTextPlainCodeBlockWithNewlineWithEmptyLines(); - void testProcessTextWithoutCodeBlock(); - void testDetectLanguageFromLine(); - void testDetectLanguageFromExtension(); - void testCommentPrefixForDifferentLanguages(); - void testHasCodeBlocks(); - - void testReplaceLengthEmptyRight(); - void testReplaceLengthNoOverlap(); - void testReplaceLengthSingleCharNoOverlap(); - void testReplaceLengthLcpExtendsRight(); - void testReplaceLengthLcpPartialEngineCall(); - void testReplaceLengthLcpThenClosingTailExtendsFull(); - void testReplaceLengthClosingTailReplaceSemicolon(); - void testReplaceLengthClosingTailReplaceParen(); - void testReplaceLengthClosingTailReplaceBrackets(); - void testReplaceLengthClosingTailReplaceBracesAndSemi(); - void testReplaceLengthClosingTailWithLeadingSpace(); - void testReplaceLengthClosingTailEqualsSemi(); - void testReplaceLengthClosingTailNoMatchingClose(); - void testReplaceLengthRealCodeRightNoLcp(); - void testReplaceLengthTrailingWhitespaceOnlyLeftAlone(); - - void testCacheControlBreakpointWithoutExtendedTtl(); - void testCacheControlBreakpointWithExtendedTtl(); - void testCacheControlSystemAsStringWrappedIntoArray(); - void testCacheControlEmptySystemStringIsNotWrapped(); - void testCacheControlSystemAsArrayMarksLastBlock(); - void testCacheControlToolsLastEntryGetsCacheControl(); - void testCacheControlSingleMessageHistorySkipped(); - void testCacheControlHistoryBreakpointOnSecondToLastMessage(); - void testCacheControlHistoryArrayContentMarksLastBlock(); - void testCacheControlNoSystemNoToolsNoMessagesIsNoop(); - void testCacheControlEmptyToolsArrayIsNoop(); - - void testGetLineText(); - void testGetContext(); - void testGetContextWithCopyright(); - void testReadWholeFile(); - void testReadWholeFileWithCopyright(); - void testReadWholeFileWithMultilineCopyright(); - void testFindCopyrightSingleLine(); - void testFindCopyrightMultiLine(); - void testFindCopyrightMultipleBlocks(); - void testFindCopyrightNoCopyright(); - void testGetContextBetween(); - void testPrepareContext(); - - void testHistorySnapshotUsesCurrentVersion(); - void testHistorySnapshotRoundTrip(); - void testUnsupportedChatVersionIsRejected(); - void testChatFileWithoutMessagesArrayIsRejected(); - void testLegacyChatFileConvertsToHistory(); - void testLegacyToolRowWithoutMetadataKeepsItsText(); - void testChatRowsRoundTripThroughHistory(); - void testCompressedChatShapeReloadsAsOneAssistantRow(); - void testHistoryAppliesToChatModel(); - void testAdjacentThinkingBlocksSurviveReload(); - - void testSessionAppendsUserTurnBeforeSending(); - void testSessionStreamsTextThinkingAndTools(); - void testSessionTrimsStreamedText(); - void testSessionAbandoningTheTurnCancelsTheBackend(); - void testSwitchingBackendsCancelsTheActiveTurn(); - void testSessionForwardsTitleFromTheEventStream(); - void testSessionProjectionMatchesHistory(); - void testSessionAccumulatesStreamedThinking(); - void testSessionKeepsThinkingAfterToolContinuation(); - void testSessionDropsPreToolTextWhenAsked(); - void testSessionToolResultAppendsFileEditRow(); - void testSessionRepeatedToolResultKeepsOneFileEdit(); - void testRunningToolStatusInterruptsOnTurnEnd(); - void testRunningToolStatusInterruptsOnLoad(); - void testAgentDiffBecomesAppliedFileEdit(); - void testAgentDiffSkipsUnrevertableRegistrations(); - void testAgentFileEditRevertRoundTrip(); - void testSessionIgnoresEchoedFileEditMarker(); - void testSessionTruncationKeepsBlockOrder(); - void testHistorySnapshotReportsUnreadableBlocks(); - void testSessionUsageLandsOnTheTurn(); - void testSessionIgnoresEventsFromOtherTurns(); - void testSessionReportsFailureWithoutStartedTurn(); - void testSessionIgnoresFailureFromAbandonedTurn(); - void testSessionTruncateRowsRewritesHistory(); - - void testSessionUpsertsAgentToolCalls(); - void testLlmToolLifecycleFlowsThroughOneEvent(); - void testSessionKeepsOnePlanPerTurn(); - void testAgentActivityKeepsStreamedTextIntact(); - void testAgentToolUpdateTouchesOnlyItsOwnRow(); - void testUsageSkipsRowsThatCannotShowIt(); - void testAgentActivityBlocksSurviveReload(); - - void testEveryRowKindProjectsInOrder(); - void testPromptAudienceKeepsToolsAndThinking(); - void testCompressionAudienceDropsToolsAndThinking(); - void testTokenCountAudienceCountsToolsAndThinking(); - - void testSessionRendersPermissionRequestWithItsOptions(); - void testSessionForwardsPermissionAnswerToTheBackend(); - void testSessionAllowAlwaysSuppressesRepeatPromptsOfTheSameKind(); - void testSessionAllowAlwaysDoesNotCoverOtherToolKinds(); - void testSessionNeverAutoAnswersUnclassifiedTools(); - void testSessionIgnoresAnswersToUnknownPermissionRequests(); - void testSessionMarksUnansweredPermissionsCancelled(); - void testSessionRejectAlwaysSuppressesRepeatPromptsOfTheSameKind(); - void testSessionAutoAnswerFallsBackToTheAlwaysOption(); - void testSessionDoesNotAutoAnswerWithoutAMatchingOption(); - void testSessionAllowAlwaysDoesNotSurviveAnotherConversation(); - void testSessionAnswersTheLiveRequestWhenAnOldIdIsReused(); - void testSessionCancelsPermissionsTheBackendNeverResolved(); - void testSessionCancelsPermissionsTheBackendRefused(); - void testPermissionBlockReopensAsUnanswerable(); - - void testTurnContextCollectsPartsFromPorts(); - void testTurnContextSkipsWhatTheBackendDoesNotNeed(); - void testFencedFileBlockOutgrowsBackticksInContent(); - void testTurnContextSkillCommandScanning(); - void testTurnContextWithoutSkillsPort(); - void testSystemPromptRenderingWithProject(); - void testSystemPromptRenderingWithoutProject(); - - void testAgentRegistryParsesEveryDistributionKind(); - void testAgentRegistryReportsUnusableEntries(); - void testAgentRegistryParsesSingleAgentUserFile(); - void testAgentCatalogAppliesMergePriority(); - void testAgentCatalogUserFileMakesBinaryAgentLaunchable(); - void testAgentLaunchConfigUsesRunnerConventions(); - void testAgentSearchPathsSplitting(); - void testAgentExtraSearchPathsReachTheChildEnvironment(); - void testAgentForwardedVariablesReachTheChildEnvironment(); - void testBundledAgentSnapshotParses(); - - void testAcpBackendStreamsTextAndThinking(); - void testAcpBackendStartsSessionInWorkingDirectory(); - void testAcpBackendAuthenticatesWhenTheAgentAsksForIt(); - void testAcpBackendReportsPromptFailure(); - void testAcpBackendCancelInterruptsTheTurn(); - void testAcpBackendSendsAttachmentsAsEmbeddedResources(); - void testAcpBackendInlinesAttachmentsWithoutEmbeddedContextSupport(); - void testAcpBackendReportsAttachmentsItCannotLoad(); - void testAcpBackendSendsImagesWhenTheAgentAcceptsThem(); - void testAcpBackendNamesImagesTheAgentCannotAccept(); - void testAcpBackendFencesAttachmentsThatContainFences(); - void testAcpBackendTruncatesOversizedAttachments(); - void testAcpBackendRejectsAnEmptyTurn(); - void testAcpBackendSendsAttachmentsWithoutAnyMessageText(); - void testAcpBackendRaisesAgentPermissionRequests(); - void testAcpBackendSendsThePermissionAnswerToTheAgent(); - void testAcpBackendCancelsOutstandingPermissionRequests(); - - void testTurnLedgerTracksTheActiveTurn(); - void testTurnLedgerDrainsPermissionsOnTurnEnd(); - void testLlmBackendStreamsThroughAFakeClient(); - void testRenderHistoryKeepsToolExchangePairs(); - void testLlmBackendIgnoresEventsFromOtherRequests(); - void testLlmBackendPermissionRoundTrip(); - void testLlmBackendDrainsPermissionsWhenTheTurnEnds(); - - void testCoordinatorSwitchConfirmAndCancelRoundTrip(); - void testCoordinatorQuarantinesAndRestoresAgentBindings(); - void testCoordinatorRefusesIntentsWhileReadOnly(); - void testCoordinatorDefersTheSendForAutoCompress(); - void testCoordinatorGatesTheSummaryHandover(); - void testCoordinatorShrinkContextRoutesByBackendKind(); - - void testAcpBackendTracksAgentSlashCommands(); - void testEarlyAgentCommandsSurviveEstablishment(); - void testAgentCommandInvocationSendsPlainPrompt(); - - void testFileMentionSelectionFollowsChatToolsSetting(); - - void testChatModelExposesSessionRowsDirectly(); - - void testBlockCodecRejectsForgedMarkers(); - void testFileEditParsingRequiresTheMarkerAtTheStart(); - void testPermissionOptionsCarryAllowsAcrossTheSeam(); - - void testChatFileStoreRoundTripsStoredContent(); - void testLegacyChatFileLoadsThroughTheFileStore(); - - void testPickerObservesTheSharedAgentCatalog(); - void testAcpBackendSeedsAHandoverSummaryOnce(); - void testAcpBackendDropsAHandoverSummaryAfterACancelledTurn(); - void testAcpBackendDropsAHandoverSummaryWithTheConversation(); - void testToolSafetyDefaultsToMutating(); - void testToolsManagerWithoutAGateRunsImmediately(); - void testToolsManagerGateCanDeclineAToolCall(); - void testToolsManagerGateCanAllowAToolCall(); - void testToolsManagerGateResumesTheQueueAfterADenial(); - void testKnowledgeServerExposesOnlyReadOnlyTools(); - void testKnowledgeServerBindsLoopbackOnAnEphemeralPort(); - void testAcpBackendOffersKnowledgeToHttpCapableAgents(); - void testAcpBackendWithholdsKnowledgeFromAgentsWithoutHttpMcp(); - void testAcpBackendStopsTheKnowledgeServerWithTheSession(); - void testAcpBackendSurvivesAKnowledgeServerThatWillNotStart(); - void testAcpBackendResumesAPersistedSession(); - void testAcpBackendReportsAnUnresumableSession(); - void testAcpBackendRefusesToResumeWithoutAgentSupport(); - void testAcpBackendStartsFreshAfterAFailedResume(); - void testAgentBindingKeepsTheResumeTargetBeforeTheFirstTurn(); - void testAcpBackendCarriesTheLiveSessionIntoAResume(); - void testAcpBackendEstablishesTheSessionOnlyOnce(); - void testAgentBindingRejectsMalformedRecords(); - void testMalformedAgentBindingLeavesTheChatUnbound(); - void testAgentBindingRoundTripsThroughTheChatFile(); - void testChatFileWithoutAnAgentLoadsUnbound(); - void testAcpBackendMapsToolCallLifecycle(); - void testAcpBackendMapsPlanUpdates(); - void testAcpBackendReportsUsageFromThePromptResult(); - void testAcpBackendIgnoresTheCumulativeContextGauge(); - void testAcpBackendForwardsTheAgentSuggestedTitle(); - void testAcpBackendDeclinesAmbiguousPermissionOptions(); - void testAcpBackendClampsOversizedPermissionText(); - void testAcpBackendCancelsPermissionsWhenTheTurnCompletes(); - void testAcpBackendCancelsPermissionRequestsWhenTheTurnFails(); - -private: - Context::DocumentContextReader createReader(const QString &text); - QSharedPointer createSettingsForWholeFile(); - QSharedPointer createSettingsForLines( - int linesBefore, int linesAfter); - static QJsonObject expectedEphemeral(bool extendedTtl); -}; - -} // namespace QodeAssist diff --git a/tests/RowAudienceTest.cpp b/tests/RowAudienceTest.cpp new file mode 100644 index 0000000..a9ec83b --- /dev/null +++ b/tests/RowAudienceTest.cpp @@ -0,0 +1,132 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "RowAudienceTest.hpp" + +#include + +#include "session/AgentPlan.hpp" +#include "session/ContentBlock.hpp" +#include "session/HistoryProjection.hpp" +#include "session/PermissionRequest.hpp" +#include "session/Session.hpp" + +namespace QodeAssist { + +namespace { + +Session::ConversationHistory historyWithEveryRowKind() +{ + Session::Message system; + system.role = Session::MessageRole::System; + system.id = "s1"; + system.blocks = {Session::TextBlock{"system"}}; + + Session::Message user; + user.role = Session::MessageRole::User; + user.id = "u1"; + user.blocks = {Session::TextBlock{"question"}}; + + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "a1"; + assistant.blocks + = {Session::TextBlock{"answer"}, + Session::ThinkingBlock{"thinking", {}, false}, + Session::ToolCallBlock{"call-1", "read_file", {}, "int main() {}", {}, {}, {}, false}, + Session::ToolCallBlock{ + "call-2", "Edit main.cpp", {}, "done", "edit", "completed", {}, true}, + Session::FileEditBlock{"edit-1", "payload"}, + Session::PermissionBlock{"p1", "tool-p1", "Edit main.cpp", "edit", {}}, + Session::PlanBlock{{Session::PlanEntry{"Step", "high", "pending"}}}}; + + Session::ConversationHistory history; + history.append(system); + history.append(user); + history.append(assistant); + return history; +} + +QList projectedKinds() +{ + QList kinds; + for (const Session::MessageRow &row : Session::projectToRows(historyWithEveryRowKind())) + kinds.append(row.kind); + return kinds; +} + +QList survivingKinds(Session::RowAudience audience) +{ + QList kept; + for (const Session::MessageRow &row : Session::projectToRows(historyWithEveryRowKind())) { + if (Session::rowTreatmentFor(audience, row.kind) != Session::RowTreatment::Omit) + kept.append(row.kind); + } + return kept; +} + +} // namespace + +void RowAudienceTest::testEveryRowKindProjectsInOrder() +{ + QCOMPARE( + projectedKinds(), + (QList{ + Session::RowKind::System, + Session::RowKind::User, + Session::RowKind::Assistant, + Session::RowKind::Thinking, + Session::RowKind::Tool, + Session::RowKind::AgentTool, + Session::RowKind::FileEdit, + Session::RowKind::Permission, + Session::RowKind::Plan})); +} + +void RowAudienceTest::testPromptAudienceKeepsToolsAndThinking() +{ + QCOMPARE( + survivingKinds(Session::RowAudience::Prompt), + (QList{ + Session::RowKind::System, + Session::RowKind::User, + Session::RowKind::Assistant, + Session::RowKind::Thinking, + Session::RowKind::Tool})); + + QCOMPARE( + Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::User), + Session::RowTreatment::UserText); + QCOMPARE( + Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::System), + Session::RowTreatment::AssistantText); + QCOMPARE( + Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::Thinking), + Session::RowTreatment::AssistantThinking); + QCOMPARE( + Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::Tool), + Session::RowTreatment::ToolExchange); +} + +void RowAudienceTest::testCompressionAudienceDropsToolsAndThinking() +{ + QCOMPARE( + survivingKinds(Session::RowAudience::Compression), + (QList{ + Session::RowKind::System, Session::RowKind::User, Session::RowKind::Assistant})); +} + +void RowAudienceTest::testTokenCountAudienceCountsToolsAndThinking() +{ + QCOMPARE( + survivingKinds(Session::RowAudience::TokenCount), + (QList{ + Session::RowKind::System, + Session::RowKind::User, + Session::RowKind::Assistant, + Session::RowKind::Thinking, + Session::RowKind::Tool})); +} + +} // namespace QodeAssist diff --git a/tests/RowAudienceTest.hpp b/tests/RowAudienceTest.hpp new file mode 100644 index 0000000..c42ff49 --- /dev/null +++ b/tests/RowAudienceTest.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 RowAudienceTest final : public QObject +{ + Q_OBJECT + +private slots: + void testEveryRowKindProjectsInOrder(); + void testPromptAudienceKeepsToolsAndThinking(); + void testCompressionAudienceDropsToolsAndThinking(); + void testTokenCountAudienceCountsToolsAndThinking(); +}; + +} // namespace QodeAssist diff --git a/tests/SessionPermissionsTest.cpp b/tests/SessionPermissionsTest.cpp new file mode 100644 index 0000000..80234f5 --- /dev/null +++ b/tests/SessionPermissionsTest.cpp @@ -0,0 +1,391 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "SessionPermissionsTest.hpp" + +#include +#include + +#include "ChatView/ChatModel.hpp" +#include "SessionTestSupport.hpp" +#include "session/HistoryProjection.hpp" +#include "session/HistorySerializer.hpp" +#include "session/PermissionRequest.hpp" +#include "session/Session.hpp" + +namespace QodeAssist { + +void SessionPermissionsTest::testSessionRendersPermissionRequestWithItsOptions() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + QCOMPARE(session.rows().size(), 2); + QCOMPARE(session.rows().at(1).kind, Session::RowKind::Permission); + QCOMPARE(session.rows().at(1).id, QString("p1")); + + const Session::PermissionBlock block = permissionRowAt(session, 1); + QCOMPARE(block.requestId, QString("p1")); + QCOMPARE(block.toolCallId, QString("tool-p1")); + QCOMPARE(block.title, QString("Edit main.cpp")); + QCOMPARE(block.toolKind, QString("edit")); + QCOMPARE(block.options, editPermissionOptions()); + QCOMPARE(block.status, Session::PermissionStatus::Pending); + QVERIFY(!block.automatic); + QVERIFY(session.isPermissionPending("p1")); + + QVERIFY(backend.permissionAnswers().isEmpty()); +} + +void SessionPermissionsTest::testSessionForwardsPermissionAnswerToTheBackend() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + session.respondPermission("p1", "opt-no"); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(backend.permissionAnswers().at(0).first, QString("p1")); + QCOMPARE(backend.permissionAnswers().at(0).second, QString("opt-no")); + + const Session::PermissionBlock block = permissionRowAt(session, 1); + QCOMPARE(block.status, Session::PermissionStatus::Answered); + QCOMPARE(block.selectedOptionId, QString("opt-no")); + QVERIFY(!block.automatic); + QVERIFY(!session.isPermissionPending("p1")); +} + +void SessionPermissionsTest::testSessionAllowAlwaysSuppressesRepeatPromptsOfTheSameKind() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit them"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + session.respondPermission("p1", "opt-always"); + + backend.script({editPermissionRequest("r1", "p2")}); + + QCOMPARE(backend.permissionAnswers().size(), 2); + QCOMPARE(backend.permissionAnswers().at(1).first, QString("p2")); + QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-once")); + + const Session::PermissionBlock block = permissionRowAt(session, 2); + QCOMPARE(block.requestId, QString("p2")); + QCOMPARE(block.status, Session::PermissionStatus::Answered); + QCOMPARE(block.selectedOptionId, QString("opt-once")); + QVERIFY(block.automatic); + QVERIFY(!session.isPermissionPending("p2")); +} + +void SessionPermissionsTest::testSessionAllowAlwaysDoesNotCoverOtherToolKinds() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", "edit")}); + + session.respondPermission("p1", "opt-always"); + + backend.script({editPermissionRequest("r1", "p2", "execute")}); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); + QVERIFY(session.isPermissionPending("p2")); +} + +void SessionPermissionsTest::testSessionNeverAutoAnswersUnclassifiedTools() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", QString())}); + + session.respondPermission("p1", "opt-always"); + + backend.script({editPermissionRequest("r1", "p2", QString())}); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); +} + +void SessionPermissionsTest::testSessionIgnoresAnswersToUnknownPermissionRequests() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + session.respondPermission("nonexistent", "opt-once"); + session.respondPermission("p1", "no-such-option"); + session.respondPermission("p1", "opt-once"); + session.respondPermission("p1", "opt-no"); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(backend.permissionAnswers().at(0).second, QString("opt-once")); + QCOMPARE(permissionRowAt(session, 1).selectedOptionId, QString("opt-once")); +} + +void SessionPermissionsTest::testSessionMarksUnansweredPermissionsCancelled() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + backend.script( + {Session::PermissionResolved{.turnId = "r1", .requestId = "p1", .cancelled = true}, + Session::TurnFailed{"r1", "the agent went away"}}); + + const Session::PermissionBlock block = permissionRowAt(session, 1); + QCOMPARE(block.status, Session::PermissionStatus::Cancelled); + QVERIFY(block.selectedOptionId.isEmpty()); + QVERIFY(!session.isPermissionPending("p1")); + + session.respondPermission("p1", "opt-once"); + QVERIFY(backend.permissionAnswers().isEmpty()); +} + +void SessionPermissionsTest::testSessionRejectAlwaysSuppressesRepeatPromptsOfTheSameKind() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + Session::PermissionRequested first = editPermissionRequest("r1", "p1"); + first.options.append( + Session::PermissionOption{"opt-never", "No, and don't ask again", "reject_always"}); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, first}); + + session.respondPermission("p1", "opt-never"); + + Session::PermissionRequested second = editPermissionRequest("r1", "p2"); + second.options.append( + Session::PermissionOption{"opt-never", "No, and don't ask again", "reject_always"}); + backend.script({second}); + + QCOMPARE(backend.permissionAnswers().size(), 2); + QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-no")); + + const Session::PermissionBlock block = permissionRowAt(session, 2); + QCOMPARE(block.status, Session::PermissionStatus::Answered); + QCOMPARE(block.selectedOptionId, QString("opt-no")); + QVERIFY(block.automatic); +} + +void SessionPermissionsTest::testSessionAutoAnswerFallsBackToTheAlwaysOption() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + session.respondPermission("p1", "opt-always"); + + Session::PermissionRequested withoutOnce = editPermissionRequest("r1", "p2"); + withoutOnce.options = { + Session::PermissionOption{"opt-always", "Yes, always", "allow_always"}, + Session::PermissionOption{"opt-no", "No", "reject_once"}}; + backend.script({withoutOnce}); + + QCOMPARE(backend.permissionAnswers().size(), 2); + QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-always")); +} + +void SessionPermissionsTest::testSessionDoesNotAutoAnswerWithoutAMatchingOption() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + session.respondPermission("p1", "opt-always"); + + Session::PermissionRequested rejectOnly = editPermissionRequest("r1", "p2"); + rejectOnly.options = {Session::PermissionOption{"opt-no", "No", "reject_once"}}; + backend.script({rejectOnly}); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); + QVERIFY(session.isPermissionPending("p2")); +} + +void SessionPermissionsTest::testSessionAllowAlwaysDoesNotSurviveAnotherConversation() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + session.respondPermission("p1", "opt-always"); + QCOMPARE(backend.permissionAnswers().size(), 1); + + session.setHistory(Session::ConversationHistory{}); + + session.sendTurn({Session::TextBlock{"work again"}}, std::nullopt); + backend.script({Session::TurnStarted{"r2"}, editPermissionRequest("r2", "p2")}); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 1).status, Session::PermissionStatus::Pending); +} + +void SessionPermissionsTest::testSessionAnswersTheLiveRequestWhenAnOldIdIsReused() +{ + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "old"; + assistant.blocks + = {Session::PermissionBlock{ + .requestId = "p1", + .toolCallId = "tool-old", + .title = "Run make", + .toolKind = "execute", + .options = editPermissionOptions(), + .status = Session::PermissionStatus::Cancelled}}; + + Session::ConversationHistory history; + history.append(assistant); + + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + session.setHistory(history); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", "edit")}); + + session.respondPermission("p1", "opt-always"); + + QCOMPARE(backend.permissionAnswers().size(), 1); + + const Session::PermissionBlock stale = permissionRowAt(session, 0); + QCOMPARE(stale.status, Session::PermissionStatus::Cancelled); + QVERIFY(stale.selectedOptionId.isEmpty()); + + const Session::PermissionBlock live = permissionRowAt(session, 2); + QCOMPARE(live.status, Session::PermissionStatus::Answered); + QCOMPARE(live.selectedOptionId, QString("opt-always")); + + backend.script({editPermissionRequest("r1", "p3", "edit")}); + QCOMPARE(backend.permissionAnswers().size(), 2); + + backend.script({editPermissionRequest("r1", "p4", "execute")}); + QCOMPARE(backend.permissionAnswers().size(), 2); +} + +void SessionPermissionsTest::testSessionCancelsPermissionsTheBackendNeverResolved() +{ + FakeChatBackend backend; + backend.setAutoResolvePermissions(false); + + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + editPermissionRequest("r1", "p1"), + Session::TurnCompleted{"r1"}}); + + QCOMPARE(permissionRowAt(session, 1).status, Session::PermissionStatus::Cancelled); + QVERIFY(!session.isPermissionPending("p1")); +} + +void SessionPermissionsTest::testSessionCancelsPermissionsTheBackendRefused() +{ + FakeChatBackend backend; + backend.setAcceptPermissionAnswers(false); + + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + session.respondPermission("p1", "opt-always"); + + const Session::PermissionBlock block = permissionRowAt(session, 1); + QCOMPARE(block.status, Session::PermissionStatus::Cancelled); + QVERIFY(block.selectedOptionId.isEmpty()); + QVERIFY(!session.isPermissionPending("p1")); + + backend.script({editPermissionRequest("r1", "p2")}); + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); +} + +void SessionPermissionsTest::testPermissionBlockReopensAsUnanswerable() +{ + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r1"; + assistant.blocks + = {Session::PermissionBlock{ + .requestId = "p1", + .toolCallId = "tool-1", + .title = "Edit main.cpp", + .toolKind = "edit", + .options = editPermissionOptions(), + .status = Session::PermissionStatus::Answered, + .selectedOptionId = "opt-once", + .automatic = false}, + Session::PermissionBlock{ + .requestId = "p2", + .toolCallId = "tool-2", + .title = "Run make", + .toolKind = "execute", + .options = editPermissionOptions(), + .status = Session::PermissionStatus::Pending}}; + + Session::ConversationHistory history; + history.append(assistant); + + const QJsonObject json = Session::HistorySerializer::toJson(history); + const auto reloaded = Session::HistorySerializer::fromJson(json); + QVERIFY(reloaded.has_value()); + QCOMPARE(reloaded->size(), 1); + QCOMPARE(reloaded->at(0).blocks.size(), 2); + + const auto *answered = std::get_if(&reloaded->at(0).blocks.at(0)); + QVERIFY(answered); + QCOMPARE(*answered, std::get(assistant.blocks.at(0))); + + const auto *stale = std::get_if(&reloaded->at(0).blocks.at(1)); + QVERIFY(stale); + QCOMPARE(stale->status, Session::PermissionStatus::Cancelled); + QCOMPARE(stale->options, editPermissionOptions()); + + const QList rows = Session::projectToRows(*reloaded); + QCOMPARE(rows.size(), 2); + QCOMPARE(rows.at(0).kind, Session::RowKind::Permission); + + const Session::ConversationHistory rebuilt = Session::buildFromRows(rows); + QCOMPARE(rebuilt.size(), 1); + QCOMPARE(rebuilt.at(0).blocks, reloaded->at(0).blocks); +} + +} // namespace QodeAssist diff --git a/tests/SessionPermissionsTest.hpp b/tests/SessionPermissionsTest.hpp new file mode 100644 index 0000000..a97b316 --- /dev/null +++ b/tests/SessionPermissionsTest.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 + +namespace QodeAssist { + +class SessionPermissionsTest final : public QObject +{ + Q_OBJECT + +private slots: + void testSessionRendersPermissionRequestWithItsOptions(); + void testSessionForwardsPermissionAnswerToTheBackend(); + void testSessionAllowAlwaysSuppressesRepeatPromptsOfTheSameKind(); + void testSessionAllowAlwaysDoesNotCoverOtherToolKinds(); + void testSessionNeverAutoAnswersUnclassifiedTools(); + void testSessionIgnoresAnswersToUnknownPermissionRequests(); + void testSessionMarksUnansweredPermissionsCancelled(); + void testSessionRejectAlwaysSuppressesRepeatPromptsOfTheSameKind(); + void testSessionAutoAnswerFallsBackToTheAlwaysOption(); + void testSessionDoesNotAutoAnswerWithoutAMatchingOption(); + void testSessionAllowAlwaysDoesNotSurviveAnotherConversation(); + void testSessionAnswersTheLiveRequestWhenAnOldIdIsReused(); + void testSessionCancelsPermissionsTheBackendNeverResolved(); + void testSessionCancelsPermissionsTheBackendRefused(); + void testPermissionBlockReopensAsUnanswerable(); +}; + +} // namespace QodeAssist diff --git a/tests/SessionTest.cpp b/tests/SessionTest.cpp new file mode 100644 index 0000000..e8fd46b --- /dev/null +++ b/tests/SessionTest.cpp @@ -0,0 +1,971 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "SessionTest.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "ChatView/ChatModel.hpp" +#include "SessionTestSupport.hpp" +#include "context/FileEditManager.hpp" +#include "session/FileEditPayload.hpp" +#include "session/HistoryProjection.hpp" +#include "session/HistorySerializer.hpp" +#include "session/Session.hpp" + +namespace QodeAssist { + +void SessionTest::testSessionAppendsUserTurnBeforeSending() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn( + {Session::TextBlock{"explain this"}, Session::AttachmentBlock{"notes.txt", "notes_ab.txt"}}, + std::nullopt); + + QCOMPARE(backend.turnCount(), 1); + QCOMPARE(backend.historySizeAtSend(), qsizetype(1)); + QCOMPARE(backend.lastUserBlocks().size(), 2); + QVERIFY(!backend.lastContext().has_value()); + + QCOMPARE(session.history().size(), 1); + QCOMPARE(session.history().at(0).role, Session::MessageRole::User); + QCOMPARE(session.rows().size(), 1); + QCOMPARE(session.rows().at(0).content, QString("explain this")); + QCOMPARE(session.rows().at(0).attachments.size(), 1); +} + +void SessionTest::testSessionStreamsTextThinkingAndTools() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt); + backend.script(scriptedTurn("r1")); + + QCOMPARE(session.history().size(), 2); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.role, Session::MessageRole::Assistant); + QCOMPARE(assistant.id, QString("r1")); + QCOMPARE(assistant.usage, (Session::Usage{120, 40, 80, 12})); + QCOMPARE(assistant.blocks.size(), 4); + QCOMPARE( + assistant.blocks.at(0), + (Session::ContentBlock{Session::ThinkingBlock{"weighing options", "sig-1", false}})); + QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"let me check"}})); + QCOMPARE( + assistant.blocks.at(2), + (Session::ContentBlock{Session::ToolCallBlock{ + "t1", + "read_file", + QJsonObject{{"path", "main.cpp"}}, + "int main() {}", + {}, + "completed"}})); + QCOMPARE( + assistant.blocks.at(3), (Session::ContentBlock{Session::TextBlock{"here is the answer"}})); +} + +void SessionTest::testSessionTrimsStreamedText() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::TextDelta{"r1", "\n\n"}, + Session::TextDelta{"r1", " here is"}, + Session::TextDelta{"r1", " the answer \n"}}); + + QCOMPARE(session.history().at(1).blocks.size(), 1); + QCOMPARE( + session.history().at(1).blocks.at(0), + (Session::ContentBlock{Session::TextBlock{"here is the answer"}})); +} + +void SessionTest::testSessionAbandoningTheTurnCancelsTheBackend() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); + + const int cancelsBeforeTruncate = backend.cancelCount(); + session.truncateRows(0); + QCOMPARE(backend.cancelCount(), cancelsBeforeTruncate + 1); + + const int cancelsBeforeLoad = backend.cancelCount(); + session.setHistory(Session::ConversationHistory{}); + QCOMPARE(backend.cancelCount(), cancelsBeforeLoad + 1); +} + +void SessionTest::testSwitchingBackendsCancelsTheActiveTurn() +{ + FakeChatBackend first; + FakeChatBackend second; + Session::Session session; + session.setBackend(&first); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + first.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); + + const int cancelsBeforeSwap = first.cancelCount(); + session.setBackend(&second); + QCOMPARE(first.cancelCount(), cancelsBeforeSwap + 1); + + const qsizetype rowsAfterSwap = session.rows().size(); + first.script({Session::TextDelta{"r1", " stale"}}); + QCOMPARE(session.rows().size(), rowsAfterSwap); + QCOMPARE(session.rows().at(1).content, QString("partial")); +} + +void SessionTest::testSessionForwardsTitleFromTheEventStream() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + QStringList titles; + connect( + &session, + &Session::Session::sessionInfoReceived, + this, + [&titles](const QString &title) { titles.append(title); }); + + session.sendTurn({Session::TextBlock{"hi"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::TextDelta{"r1", "hello"}, + Session::SessionInfo{.title = "Working title"}, + Session::TurnCompleted{"r1"}, + Session::SessionInfo{.title = "Chat about parsers"}}); + + QCOMPARE(titles, (QStringList{"Working title", "Chat about parsers"})); + + backend.script({Session::SessionInfo{.title = QString()}}); + QCOMPARE(titles.size(), 2); +} + +void SessionTest::testSessionProjectionMatchesHistory() +{ + Chat::ChatModel model; + FakeChatBackend backend; + Session::Session session; + attachModelToSession(session, model); + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt); + backend.script(scriptedTurn("r1")); + + const QList expected = Session::projectToRows(session.history()); + QCOMPARE(session.rows(), expected); + QCOMPARE(model.rowCount(), int(expected.size())); + + for (int row = 0; row < expected.size(); ++row) { + QCOMPARE( + model.data(model.index(row), Chat::ChatModel::Content).toString(), + expected.at(row).content); + } + + QCOMPARE(model.sessionPromptTokens(), 120); + QCOMPARE(model.sessionCompletionTokens(), 40); +} + +void SessionTest::testSessionAccumulatesStreamedThinking() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ThinkingReceived{"r1", "step", QString(), false}, + Session::ThinkingReceived{"r1", "step one", QString(), false}, + Session::ThinkingReceived{"r1", "step one and two", QString(), false}, + Session::TextDelta{"r1", "done"}}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + QCOMPARE( + assistant.blocks.at(0), + (Session::ContentBlock{Session::ThinkingBlock{"step one and two", QString(), false}})); + QCOMPARE(session.rows().size(), 3); +} + +void SessionTest::testSessionKeepsThinkingAfterToolContinuation() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ThinkingReceived{"r1", "first thought", "sig-a", false}, + llmToolStarted("r1", "t1", "read_file"), + llmToolCompleted("r1", "t1", "read_file", "contents"), + Session::ThinkingReceived{"r1", "second thought", "sig-b", false}, + Session::TextDelta{"r1", "done"}}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 4); + QCOMPARE( + assistant.blocks.at(0), + (Session::ContentBlock{Session::ThinkingBlock{"first thought", "sig-a", false}})); + QCOMPARE( + assistant.blocks.at(2), + (Session::ContentBlock{Session::ThinkingBlock{"second thought", "sig-b", false}})); +} + +void SessionTest::testSessionDropsPreToolTextWhenAsked() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::TextDelta{"r1", "leaked tool preamble"}, + llmToolStarted("r1", "t1", "read_file", {}, true), + llmToolCompleted("r1", "t1", "read_file", "contents"), + Session::TextDelta{"r1", "the answer"}}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + QCOMPARE( + assistant.blocks.at(0), + (Session::ContentBlock{Session::ToolCallBlock{ + "t1", "read_file", QJsonObject{}, "contents", {}, "completed"}})); + QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"the answer"}})); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void SessionTest::testSessionToolResultAppendsFileEditRow() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + const QString result + = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\",\"status\":\"pending\"}"; + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + llmToolStarted("r1", "t1", "edit_file"), + llmToolCompleted("r1", "t1", "edit_file", result)}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::FileEditBlock{"e1", result}})); + + QVERIFY(session.updateFileEditStatus("e1", "applied", "Successfully applied")); + + const Session::MessageRow &editRow = session.rows().last(); + QCOMPARE(editRow.kind, Session::RowKind::FileEdit); + QVERIFY(editRow.content.contains("\"status\":\"applied\"")); + QVERIFY(editRow.content.contains("\"status_message\":\"Successfully applied\"")); +} + +void SessionTest::testSessionRepeatedToolResultKeepsOneFileEdit() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + const QString result + = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\",\"status\":\"pending\"}"; + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + llmToolStarted("r1", "t1", "edit_file"), + llmToolCompleted("r1", "t1", "edit_file", result), + llmToolCompleted("r1", "t1", "edit_file", result)}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + + qsizetype editBlocks = 0; + for (const Session::ContentBlock &block : assistant.blocks) { + if (std::holds_alternative(block)) + ++editBlocks; + } + QCOMPARE(editBlocks, qsizetype(1)); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void SessionTest::testRunningToolStatusInterruptsOnTurnEnd() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, llmToolStarted("r1", "t1", "read_file")}); + QCOMPARE(session.rows().at(1).toolStatus, QString("in_progress")); + + session.cancel(); + + QCOMPARE(session.rows().at(1).toolStatus, QString("interrupted")); + + const auto *tool + = std::get_if(&session.history().at(1).blocks.at(0)); + QVERIFY(tool); + QCOMPARE(tool->status, QString("interrupted")); +} + +void SessionTest::testRunningToolStatusInterruptsOnLoad() +{ + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r1"; + assistant.blocks + = {Session::ToolCallBlock{ + "call-1", "Edit main.cpp", QJsonObject{}, {}, "edit", "in_progress", {}, true}}; + + Session::ConversationHistory history; + history.append(assistant); + + const auto reloaded + = Session::HistorySerializer::fromJson(Session::HistorySerializer::toJson(history)); + QVERIFY(reloaded.has_value()); + + const auto *tool = std::get_if(&reloaded->at(0).blocks.at(0)); + QVERIFY(tool); + QCOMPARE(tool->status, QString("interrupted")); + + const auto rebuilt = Session::buildFromRows(Session::projectToRows(history)); + const auto *rowTool = std::get_if(&rebuilt.at(0).blocks.at(0)); + QVERIFY(rowTool); + QCOMPARE(rowTool->status, QString("interrupted")); + + QCOMPARE(Session::restoredToolStatus("completed"), QString("completed")); + QCOMPARE(Session::restoredToolStatus("failed"), QString("failed")); + QCOMPARE(Session::restoredToolStatus(QString()), QString()); +} + +void SessionTest::testAgentDiffBecomesAppliedFileEdit() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + struct Recorded + { + QString turnId; + QString editId; + QString filePath; + QString oldContent; + QString newContent; + }; + QList recorded; + connect( + &session, + &Session::Session::agentFileEditRecorded, + this, + [&recorded]( + const QString &turnId, + const QString &editId, + const QString &filePath, + const QString &oldContent, + const QString &newContent) { + recorded.append({turnId, editId, filePath, oldContent, newContent}); + }); + + const QJsonObject details{ + {"diffs", + QJsonArray{QJsonObject{ + {"path", "/proj/main.cpp"}, {"oldText", "int a;"}, {"newText", "int b;"}}}}}; + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "in_progress", + .fromAgent = true}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "completed", + .details = details, + .fromAgent = true}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "completed", + .details = details, + .fromAgent = true}}); + + QCOMPARE(recorded.size(), 1); + QCOMPARE(recorded.at(0).turnId, QString("r1")); + QCOMPARE(recorded.at(0).editId, QString("agent-call-1-0")); + QCOMPARE(recorded.at(0).filePath, QString("/proj/main.cpp")); + QCOMPARE(recorded.at(0).oldContent, QString("int a;")); + QCOMPARE(recorded.at(0).newContent, QString("int b;")); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + + const auto *edit = std::get_if(&assistant.blocks.at(1)); + QVERIFY(edit); + QCOMPARE(edit->id, QString("agent-call-1-0")); + + const auto payload = Session::parseFileEditPayload(edit->payload); + QVERIFY(payload.has_value()); + QCOMPARE(payload->value("status").toString(), QString("applied")); + QCOMPARE(payload->value("file").toString(), QString("/proj/main.cpp")); + QCOMPARE(payload->value("old_content").toString(), QString("int a;")); + QCOMPARE(payload->value("new_content").toString(), QString("int b;")); + + const Session::MessageRow &editRow = session.rows().last(); + QCOMPARE(editRow.kind, Session::RowKind::FileEdit); + QVERIFY(editRow.content.contains("\"status\":\"applied\"")); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void SessionTest::testAgentDiffSkipsUnrevertableRegistrations() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + int recorded = 0; + connect( + &session, + &Session::Session::agentFileEditRecorded, + this, + [&recorded]( + const QString &, const QString &, const QString &, const QString &, const QString &) { + ++recorded; + }); + + const QJsonObject details{ + {"diffs", + QJsonArray{ + QJsonObject{{"path", "/proj/deleted.cpp"}, {"oldText", "gone"}, {"newText", ""}}, + QJsonObject{ + {"path", "/proj/big.cpp"}, + {"oldText", "int a;"}, + {"newText", "int b;"}, + {"truncated", true}}}}}; + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit files", + .kind = "edit", + .status = "completed", + .details = details, + .fromAgent = true}}); + + QCOMPARE(recorded, 0); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 3); + QVERIFY(std::get_if(&assistant.blocks.at(1))); + QVERIFY(std::get_if(&assistant.blocks.at(2))); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void SessionTest::testAgentFileEditRevertRoundTrip() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString filePath = dir.filePath("edited.cpp"); + const QString oldContent = "int a;\n"; + const QString newContent = "int b;\n"; + + { + QFile file(filePath); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(newContent.toUtf8()); + } + + const QString editId + = "agent-test-" + QUuid::createUuid().toString(QUuid::WithoutBraces); + const QString requestId + = "req-" + QUuid::createUuid().toString(QUuid::WithoutBraces); + + auto &changes = Context::FileEditManager::instance(); + changes.registerAppliedFileEdit(editId, filePath, oldContent, newContent, requestId); + + const auto registered = changes.getFileEdit(editId); + QCOMPARE(registered.status, Context::FileEditManager::Applied); + QCOMPARE(registered.filePath, filePath); + + QVERIFY(changes.applyPendingEditsForRequest(requestId)); + + QSignalSpy undoneSpy(&changes, &Context::FileEditManager::fileEditUndone); + QVERIFY(changes.undoFileEdit(editId)); + QCOMPARE(undoneSpy.count(), 1); + QCOMPARE(undoneSpy.at(0).at(0).toString(), editId); + + QFile file(filePath); + QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); + QCOMPARE(QString::fromUtf8(file.readAll()), oldContent); + + QCOMPARE(changes.getFileEdit(editId).status, Context::FileEditManager::Rejected); +} + +void SessionTest::testSessionIgnoresEchoedFileEditMarker() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + const QString echoed + = "Pre-compression history (/src): 1 matching message(s)\n\n[#3 assistant]\n" + "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\"}"; + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + llmToolStarted("r1", "t1", "read_original_history"), + llmToolCompleted("r1", "t1", "read_original_history", echoed)}); + + QCOMPARE(session.history().at(1).blocks.size(), 1); + QCOMPARE(session.rows().size(), 2); +} + +void SessionTest::testSessionTruncationKeepsBlockOrder() +{ + Session::Message user; + user.role = Session::MessageRole::User; + user.blocks + = {Session::TextBlock{"look at this"}, + Session::AttachmentBlock{"notes.txt", "notes_ab.txt"}, + Session::ImageBlock{"shot.png", "shot_cd.png", "image/png"}}; + + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r1"; + assistant.blocks = {Session::TextBlock{"sure"}}; + + Session::ConversationHistory history; + history.append(user); + history.append(assistant); + + Session::Session session; + session.setHistory(history); + session.truncateRows(1); + + QCOMPARE(session.history().size(), 1); + QCOMPARE(session.history().at(0).blocks, user.blocks); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void SessionTest::testHistorySnapshotReportsUnreadableBlocks() +{ + QJsonObject block; + block["type"] = "from_a_newer_build"; + + QJsonObject message; + message["role"] = "assistant"; + message["blocks"] = QJsonArray{block, QJsonObject{{"type", "text"}, {"text", "kept"}}}; + + QJsonObject root; + root["version"] = Session::HistorySerializer::currentVersion(); + root["messages"] = QJsonArray{message}; + + int dropped = -1; + const auto history = Session::HistorySerializer::fromJson(root, &dropped); + + QVERIFY(history.has_value()); + QCOMPARE(dropped, 1); + QCOMPARE(history->at(0).blocks.size(), 1); +} + +void SessionTest::testSessionUsageLandsOnTheTurn() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ThinkingReceived{"r1", "hmm", "sig", false}, + Session::TextDelta{"r1", "done"}, + Session::UsageReported{"r1", Session::Usage{10, 5, 2, 1}}, + Session::TurnCompleted{"r1"}}); + + QCOMPARE(session.history().at(1).usage, (Session::Usage{10, 5, 2, 1})); + QCOMPARE(session.rows().at(1).usage, (Session::Usage{10, 5, 2, 1})); + QCOMPARE(session.rows().at(2).usage, Session::Usage{}); +} + +void SessionTest::testSessionIgnoresEventsFromOtherTurns() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::TextDelta{"r1", "mine"}, + Session::TextDelta{"other", "not mine"}, + llmToolStarted("other", "t9", "read_file")}); + + QCOMPARE(session.history().size(), 2); + QCOMPARE(session.history().at(1).blocks.size(), 1); + QCOMPARE( + session.history().at(1).blocks.at(0), (Session::ContentBlock{Session::TextBlock{"mine"}})); +} + +void SessionTest::testSessionReportsFailureWithoutStartedTurn() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + QString reported; + QObject::connect( + &session, &Session::Session::turnFailed, &session, [&reported](const QString &error) { + reported = error; + }); + + backend.script({Session::TurnFailed{QString(), "no provider configured"}}); + + QCOMPARE(reported, QString("no provider configured")); +} + +void SessionTest::testSessionIgnoresFailureFromAbandonedTurn() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + int reported = 0; + QObject::connect(&session, &Session::Session::turnFailed, &session, [&reported](const QString &) { + ++reported; + }); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); + session.truncateRows(0); + + backend.script({Session::TurnFailed{"r1", "stale timeout"}}); + + QCOMPARE(reported, 0); +} + +void SessionTest::testSessionTruncateRowsRewritesHistory() +{ + Chat::ChatModel model; + FakeChatBackend backend; + Session::Session session; + attachModelToSession(session, model); + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt); + backend.script(scriptedTurn("r1")); + + const qsizetype before = session.rows().size(); + QVERIFY(before > 1); + + session.truncateRows(1); + + QCOMPARE(session.rows().size(), qsizetype(1)); + QCOMPARE(model.rowCount(), 1); + QCOMPARE(session.history().size(), 1); + QCOMPARE(session.history().at(0).role, Session::MessageRole::User); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void SessionTest::testSessionUpsertsAgentToolCalls() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "pending", + .fromAgent = true}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "completed", + .result = "done", + .details = QJsonObject{{"locations", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}}, + .fromAgent = true}}); + + QCOMPARE(session.history().size(), 2); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 1); + + const auto *tool = std::get_if(&assistant.blocks.at(0)); + QVERIFY(tool); + QCOMPARE(tool->id, QString("call-1")); + QCOMPARE(tool->name, QString("Edit main.cpp")); + QCOMPARE(tool->kind, QString("edit")); + QCOMPARE(tool->status, QString("completed")); + QCOMPARE(tool->result, QString("done")); + QVERIFY(tool->details.contains("locations")); + + QCOMPARE(session.rows().size(), 2); + QCOMPARE(session.rows().at(1).kind, Session::RowKind::AgentTool); + QCOMPARE(session.rows().at(1).toolStatus, QString("completed")); + QCOMPARE(session.rows().at(1).toolKind, QString("edit")); + QCOMPARE(session.rows().at(1).toolDetails, tool->details); + QVERIFY(tool->fromAgent); +} + +void SessionTest::testLlmToolLifecycleFlowsThroughOneEvent() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + llmToolStarted("r1", "t1", "read_file", QJsonObject{{"path", "main.cpp"}})}); + + QCOMPARE(session.rows().size(), 2); + QCOMPARE(session.rows().at(1).kind, Session::RowKind::Tool); + QCOMPARE(session.rows().at(1).toolStatus, QString("in_progress")); + + backend.script( + {Session::TextDelta{"r1", "working"}, + llmToolCompleted("r1", "t1", "read_file", "int main() {}"), + Session::TextDelta{"r1", " still"}}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + + const auto *tool = std::get_if(&assistant.blocks.at(0)); + QVERIFY(tool); + QCOMPARE(tool->status, QString("completed")); + QCOMPARE(tool->result, QString("int main() {}")); + QCOMPARE(tool->arguments, (QJsonObject{{"path", "main.cpp"}})); + QVERIFY(!tool->fromAgent); + + QCOMPARE( + assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"working still"}})); + QCOMPARE(session.rows().at(1).toolStatus, QString("completed")); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void SessionTest::testSessionKeepsOnePlanPerTurn() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"fix it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::PlanUpdated{ + .turnId = "r1", + .entries = {Session::PlanEntry{"Read", "high", "in_progress"}, + Session::PlanEntry{"Fix", "medium", "pending"}}}, + Session::TextDelta{"r1", "working"}, + Session::PlanUpdated{ + .turnId = "r1", + .entries = {Session::PlanEntry{"Read", "high", "completed"}, + Session::PlanEntry{"Fix", "medium", "in_progress"}}}}); + + const Session::Message &assistant = session.history().at(1); + + qsizetype planBlocks = 0; + const Session::PlanBlock *plan = nullptr; + for (const Session::ContentBlock &block : assistant.blocks) { + if (const auto *candidate = std::get_if(&block)) { + ++planBlocks; + plan = candidate; + } + } + + QCOMPARE(planBlocks, qsizetype(1)); + QVERIFY(plan); + QCOMPARE(plan->entries.size(), 2); + QCOMPARE(plan->entries.at(0).status, QString("completed")); + QCOMPARE(plan->entries.at(1).status, QString("in_progress")); + + qsizetype planRows = 0; + for (const Session::MessageRow &row : session.rows()) { + if (row.kind == Session::RowKind::Plan) + ++planRows; + } + QCOMPARE(planRows, qsizetype(1)); +} + +void SessionTest::testAgentActivityKeepsStreamedTextIntact() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Read main.cpp", + .status = "pending", + .fromAgent = true}, + Session::TextDelta{"r1", "Reading the file"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Read main.cpp", + .status = "completed", + .fromAgent = true}, + Session::TextDelta{"r1", " and it is done."}}); + + const Session::Message &assistant = session.history().at(1); + + QStringList streamed; + for (const Session::ContentBlock &block : assistant.blocks) { + if (const auto *text = std::get_if(&block)) + streamed.append(text->text); + } + + QCOMPARE(streamed, QStringList{"Reading the file and it is done."}); +} + +void SessionTest::testAgentToolUpdateTouchesOnlyItsOwnRow() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Read a", + .status = "pending", + .fromAgent = true}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-2", + .name = "Read b", + .status = "pending", + .fromAgent = true}}); + + QList updatedRows; + QObject::connect( + &session, + &Session::Session::rowUpdated, + &session, + [&updatedRows](int index, const Session::MessageRow &) { updatedRows.append(index); }); + + backend.script( + {Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Read a", + .status = "completed", + .fromAgent = true}}); + + QCOMPARE(updatedRows, QList{1}); + QCOMPARE(session.rows().at(1).toolStatus, QString("completed")); + QCOMPARE(session.rows().at(2).toolStatus, QString("pending")); +} + +void SessionTest::testUsageSkipsRowsThatCannotShowIt() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::PlanUpdated{ + .turnId = "r1", .entries = {Session::PlanEntry{"Read", "high", "in_progress"}}}, + Session::TextDelta{"r1", "here is the answer"}, + Session::UsageReported{"r1", Session::Usage{120, 40, 80, 12}}}); + + const Session::MessageRow &planRow = session.rows().at(1); + const Session::MessageRow &textRow = session.rows().at(2); + + QCOMPARE(planRow.kind, Session::RowKind::Plan); + QVERIFY(planRow.usage.isEmpty()); + + QCOMPARE(textRow.kind, Session::RowKind::Assistant); + QCOMPARE(textRow.usage, (Session::Usage{120, 40, 80, 12})); +} + +void SessionTest::testAgentActivityBlocksSurviveReload() +{ + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r1"; + assistant.blocks + = {Session::PlanBlock{ + {Session::PlanEntry{"Read", "high", "completed"}, + Session::PlanEntry{"Fix", "medium", "in_progress"}}}, + Session::ToolCallBlock{ + "call-1", + "Edit main.cpp", + QJsonObject{}, + "done", + "edit", + "completed", + QJsonObject{{"diffs", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}}, + true}}; + + Session::ConversationHistory history; + history.append(assistant); + + const auto reloaded = Session::HistorySerializer::fromJson( + Session::HistorySerializer::toJson(history)); + QVERIFY(reloaded.has_value()); + QCOMPARE(*reloaded, history); + + const QList rows = Session::projectToRows(history); + QCOMPARE(rows.size(), 2); + QCOMPARE(rows.at(0).kind, Session::RowKind::Plan); + QCOMPARE(rows.at(1).kind, Session::RowKind::AgentTool); + QVERIFY(Session::isTranscriptOnlyRow(rows.at(0).kind)); + QVERIFY(Session::isTranscriptOnlyRow(rows.at(1).kind)); + QCOMPARE(Session::buildFromRows(rows), history); +} + +} // namespace QodeAssist diff --git a/tests/SessionTest.hpp b/tests/SessionTest.hpp new file mode 100644 index 0000000..09e861b --- /dev/null +++ b/tests/SessionTest.hpp @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +namespace QodeAssist { + +class SessionTest final : public QObject +{ + Q_OBJECT + +private slots: + void testSessionAppendsUserTurnBeforeSending(); + void testSessionStreamsTextThinkingAndTools(); + void testSessionTrimsStreamedText(); + void testSessionAbandoningTheTurnCancelsTheBackend(); + void testSwitchingBackendsCancelsTheActiveTurn(); + void testSessionForwardsTitleFromTheEventStream(); + void testSessionProjectionMatchesHistory(); + void testSessionAccumulatesStreamedThinking(); + void testSessionKeepsThinkingAfterToolContinuation(); + void testSessionDropsPreToolTextWhenAsked(); + void testSessionToolResultAppendsFileEditRow(); + void testSessionRepeatedToolResultKeepsOneFileEdit(); + void testRunningToolStatusInterruptsOnTurnEnd(); + void testRunningToolStatusInterruptsOnLoad(); + void testAgentDiffBecomesAppliedFileEdit(); + void testAgentDiffSkipsUnrevertableRegistrations(); + void testAgentFileEditRevertRoundTrip(); + void testSessionIgnoresEchoedFileEditMarker(); + void testSessionTruncationKeepsBlockOrder(); + void testHistorySnapshotReportsUnreadableBlocks(); + void testSessionUsageLandsOnTheTurn(); + void testSessionIgnoresEventsFromOtherTurns(); + void testSessionReportsFailureWithoutStartedTurn(); + void testSessionIgnoresFailureFromAbandonedTurn(); + void testSessionTruncateRowsRewritesHistory(); + void testSessionUpsertsAgentToolCalls(); + void testLlmToolLifecycleFlowsThroughOneEvent(); + void testSessionKeepsOnePlanPerTurn(); + void testAgentActivityKeepsStreamedTextIntact(); + void testAgentToolUpdateTouchesOnlyItsOwnRow(); + void testUsageSkipsRowsThatCannotShowIt(); + void testAgentActivityBlocksSurviveReload(); +}; + +} // namespace QodeAssist diff --git a/tests/SessionTestSupport.hpp b/tests/SessionTestSupport.hpp new file mode 100644 index 0000000..6362615 --- /dev/null +++ b/tests/SessionTestSupport.hpp @@ -0,0 +1,208 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include +#include + +#include + +#include "ChatView/ChatModel.hpp" +#include "session/BlockCodec.hpp" +#include "session/ChatBackend.hpp" +#include "session/ContentBlock.hpp" +#include "session/ConversationHistory.hpp" +#include "session/HistoryProjection.hpp" +#include "session/PermissionRequest.hpp" +#include "session/Session.hpp" +#include "session/SessionEvent.hpp" +#include "session/TurnRequest.hpp" + +namespace QodeAssist { + +inline Session::Message userTurn() +{ + Session::Message message; + message.role = Session::MessageRole::User; + message.id = "u1"; + message.blocks + = {Session::TextBlock{"explain this"}, + Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}, + Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}; + return message; +} + +inline Session::Message assistantTurn() +{ + Session::Message message; + message.role = Session::MessageRole::Assistant; + message.id = "r1"; + message.blocks + = {Session::ThinkingBlock{"weighing options", "sig-1", false}, + Session::ToolCallBlock{ + "t1", "read_file", QJsonObject{{"path", "main.cpp"}}, "int main() {}"}, + Session::TextBlock{"here is the answer"}}; + message.usage = Session::Usage{120, 40, 80, 12}; + return message; +} + +inline Session::ConversationHistory sampleHistory() +{ + Session::Message assistant = assistantTurn(); + assistant.blocks.append( + Session::FileEditBlock{"e1", "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\"}"}); + + Session::ConversationHistory history; + history.append(userTurn()); + history.append(assistant); + return history; +} + +inline Session::ConversationHistory historyWithoutFileEdits() +{ + Session::ConversationHistory history; + history.append(userTurn()); + history.append(assistantTurn()); + return history; +} + +class FakeChatBackend : public Session::ChatBackend +{ +public: + void sendTurn(const Session::TurnRequest &request) override + { + ++m_turnCount; + m_lastUserBlocks = request.userBlocks; + m_historySizeAtSend = request.history ? request.history->size() : -1; + m_lastContext = request.context; + } + + void cancel() override { ++m_cancelCount; } + + bool respondPermission(const QString &requestId, const QString &optionId) override + { + m_permissionAnswers.append({requestId, optionId}); + if (!m_acceptPermissionAnswers) + return false; + if (m_autoResolvePermissions) + emit sessionEvent(Session::PermissionResolved{m_turnId, requestId, optionId, false}); + return true; + } + + void script(const QList &events) + { + for (const Session::SessionEvent &scripted : events) { + if (const auto *started = std::get_if(&scripted)) + m_turnId = started->turnId; + emit sessionEvent(scripted); + } + } + + void setAutoResolvePermissions(bool automatic) { m_autoResolvePermissions = automatic; } + void setAcceptPermissionAnswers(bool accept) { m_acceptPermissionAnswers = accept; } + + QList> permissionAnswers() const { return m_permissionAnswers; } + + int turnCount() const { return m_turnCount; } + int cancelCount() const { return m_cancelCount; } + qsizetype historySizeAtSend() const { return m_historySizeAtSend; } + const QList &lastUserBlocks() const { return m_lastUserBlocks; } + const std::optional &lastContext() const { return m_lastContext; } + +private: + int m_turnCount = 0; + int m_cancelCount = 0; + bool m_autoResolvePermissions = true; + bool m_acceptPermissionAnswers = true; + qsizetype m_historySizeAtSend = -1; + QString m_turnId; + QList> m_permissionAnswers; + QList m_lastUserBlocks; + std::optional m_lastContext; +}; + +inline QList editPermissionOptions() +{ + return { + Session::PermissionOption{"opt-once", "Yes", "allow_once"}, + Session::PermissionOption{"opt-always", "Yes, and don't ask again", "allow_always"}, + Session::PermissionOption{"opt-no", "No", "reject_once"}}; +} + +inline Session::PermissionRequested editPermissionRequest( + const QString &turnId, const QString &requestId, const QString &toolKind = QStringLiteral("edit")) +{ + return Session::PermissionRequested{ + .turnId = turnId, + .requestId = requestId, + .toolCallId = "tool-" + requestId, + .title = "Edit main.cpp", + .toolKind = toolKind, + .options = editPermissionOptions()}; +} + +inline Session::PermissionBlock permissionRowAt(const Session::Session &session, int index) +{ + return Session::decodePermissionBlock(session.rows().at(index).content) + .value_or(Session::PermissionBlock{}); +} + +inline Session::ToolCallUpdated llmToolStarted( + const QString &turnId, + const QString &toolId, + const QString &name, + const QJsonObject &arguments = {}, + bool dropPrecedingText = false) +{ + return Session::ToolCallUpdated{ + .turnId = turnId, + .toolId = toolId, + .name = name, + .status = "in_progress", + .arguments = arguments, + .dropPrecedingText = dropPrecedingText}; +} + +inline Session::ToolCallUpdated llmToolCompleted( + const QString &turnId, const QString &toolId, const QString &name, const QString &result) +{ + return Session::ToolCallUpdated{ + .turnId = turnId, + .toolId = toolId, + .name = name, + .status = "completed", + .result = result}; +} + +inline QList scriptedTurn(const QString &turnId) +{ + return { + Session::TurnStarted{turnId}, + Session::ThinkingReceived{turnId, "weighing options", "sig-1", false}, + Session::TextDelta{turnId, "let me "}, + Session::TextDelta{turnId, "check"}, + llmToolStarted(turnId, "t1", "read_file", QJsonObject{{"path", "main.cpp"}}), + llmToolCompleted(turnId, "t1", "read_file", "int main() {}"), + Session::TextDelta{turnId, "here is the answer"}, + Session::UsageReported{turnId, Session::Usage{120, 40, 80, 12}}, + Session::TurnCompleted{turnId}}; +} + +inline void attachModelToSession(Session::Session &session, Chat::ChatModel &model) +{ + QObject::connect( + &session, &Session::Session::rowsReset, &model, &Chat::ChatModel::resetMessages); + QObject::connect( + &session, &Session::Session::rowsAppended, &model, &Chat::ChatModel::appendMessages); + QObject::connect( + &session, &Session::Session::rowUpdated, &model, &Chat::ChatModel::updateMessage); + QObject::connect( + &session, &Session::Session::rowsRemoved, &model, &Chat::ChatModel::removeMessages); + model.resetMessages(session.rows()); +} + +} // namespace QodeAssist diff --git a/tests/ToolsManagerGateTest.cpp b/tests/ToolsManagerGateTest.cpp new file mode 100644 index 0000000..7586cc5 --- /dev/null +++ b/tests/ToolsManagerGateTest.cpp @@ -0,0 +1,176 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "ToolsManagerGateTest.hpp" + +#include +#include +#include + +#include +#include + +#include "tools/BuildProjectTool.hpp" +#include "tools/CreateNewFileTool.hpp" +#include "tools/EditFileTool.hpp" +#include "tools/EditorStateTools.hpp" +#include "tools/ExecuteTerminalCommandTool.hpp" +#include "tools/GetIssuesListTool.hpp" +#include "tools/ReadFileTool.hpp" + +namespace QodeAssist { + +namespace { + +class FakeGatedTool : public ::LLMQore::BaseTool +{ +public: + FakeGatedTool(QString toolId, ::LLMQore::ToolSafety safety, QObject *parent = nullptr) + : ::LLMQore::BaseTool(parent) + , m_id(std::move(toolId)) + , m_safety(safety) + {} + + QString id() const override { return m_id; } + QString displayName() const override { return m_id; } + QString description() const override { return m_id; } + QJsonObject parametersSchema() const override { return {}; } + ::LLMQore::ToolSafety safety() const override { return m_safety; } + + QFuture executeAsync(const QJsonObject &) override + { + ++executions; + QPromise promise; + promise.start(); + promise.addResult(LLMQore::ToolResult::text(QStringLiteral("ran ") + m_id)); + promise.finish(); + return promise.future(); + } + + int executions = 0; + +private: + QString m_id; + ::LLMQore::ToolSafety m_safety; +}; + +} // namespace + +void ToolsManagerGateTest::testToolSafetyDefaultsToMutating() +{ + FakeGatedTool undeclared("undeclared", ::LLMQore::ToolSafety::Mutating); + QCOMPARE(undeclared.safety(), ::LLMQore::ToolSafety::Mutating); + + QCOMPARE(Tools::ReadFileTool().safety(), ::LLMQore::ToolSafety::ReadOnly); + QCOMPARE(Tools::GetIssuesListTool().safety(), ::LLMQore::ToolSafety::ReadOnly); + QCOMPARE(Tools::ListOpenEditorsTool().safety(), ::LLMQore::ToolSafety::ReadOnly); + + QCOMPARE(Tools::EditFileTool().safety(), ::LLMQore::ToolSafety::Mutating); + QCOMPARE(Tools::CreateNewFileTool().safety(), ::LLMQore::ToolSafety::Mutating); + QCOMPARE(Tools::ExecuteTerminalCommandTool().safety(), ::LLMQore::ToolSafety::Mutating); + QCOMPARE(Tools::BuildProjectTool().safety(), ::LLMQore::ToolSafety::Mutating); +} + +void ToolsManagerGateTest::testToolsManagerWithoutAGateRunsImmediately() +{ + ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); + auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); + manager.addTool(tool); + + manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); + + QTRY_COMPARE(tool->executions, 1); +} + +void ToolsManagerGateTest::testToolsManagerGateCanDeclineAToolCall() +{ + ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); + auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); + manager.addTool(tool); + + QStringList gated; + manager.setExecutionGate( + [&gated]( + const QString &, const QString &, const QString &toolName, const QJsonObject &) { + gated.append(toolName); + QPromise promise; + promise.start(); + promise.addResult(false); + promise.finish(); + return promise.future(); + }); + + QString resultText; + QObject::connect( + &manager, + &::LLMQore::ToolsManager::toolExecutionResult, + &manager, + [&resultText](const QString &, const QString &, const QString &, const QString &result) { + resultText = result; + }); + + manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); + + QTRY_VERIFY(!resultText.isEmpty()); + + QCOMPARE(gated, QStringList{"edit_thing"}); + QCOMPARE(tool->executions, 0); + QVERIFY2(resultText.contains("declined"), qPrintable(resultText)); +} + +void ToolsManagerGateTest::testToolsManagerGateCanAllowAToolCall() +{ + ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); + auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); + manager.addTool(tool); + + manager.setExecutionGate( + [](const QString &, const QString &, const QString &, const QJsonObject &) { + QPromise promise; + promise.start(); + promise.addResult(true); + promise.finish(); + return promise.future(); + }); + + manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); + + QTRY_COMPARE(tool->executions, 1); +} + +void ToolsManagerGateTest::testToolsManagerGateResumesTheQueueAfterADenial() +{ + ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); + auto *denied = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); + auto *allowed = new FakeGatedTool("read_thing", ::LLMQore::ToolSafety::ReadOnly, &manager); + manager.addTool(denied); + manager.addTool(allowed); + + manager.setExecutionGate( + [](const QString &, const QString &, const QString &toolName, const QJsonObject &) { + QPromise promise; + promise.start(); + promise.addResult(toolName != QLatin1String("edit_thing")); + promise.finish(); + return promise.future(); + }); + + bool finished = false; + QObject::connect( + &manager, + &::LLMQore::ToolsManager::toolExecutionComplete, + &manager, + [&finished](const QString &, const QHash &) { + finished = true; + }); + + manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); + manager.executeToolCall("req-1", "call-2", "read_thing", QJsonObject{}); + + QTRY_VERIFY(finished); + QCOMPARE(denied->executions, 0); + QCOMPARE(allowed->executions, 1); +} + +} // namespace QodeAssist diff --git a/tests/ToolsManagerGateTest.hpp b/tests/ToolsManagerGateTest.hpp new file mode 100644 index 0000000..9b1b719 --- /dev/null +++ b/tests/ToolsManagerGateTest.hpp @@ -0,0 +1,23 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +namespace QodeAssist { + +class ToolsManagerGateTest final : public QObject +{ + Q_OBJECT + +private slots: + void testToolSafetyDefaultsToMutating(); + void testToolsManagerWithoutAGateRunsImmediately(); + void testToolsManagerGateCanDeclineAToolCall(); + void testToolsManagerGateCanAllowAToolCall(); + void testToolsManagerGateResumesTheQueueAfterADenial(); +}; + +} // namespace QodeAssist diff --git a/tests/TurnContextTest.cpp b/tests/TurnContextTest.cpp new file mode 100644 index 0000000..30f32bd --- /dev/null +++ b/tests/TurnContextTest.cpp @@ -0,0 +1,196 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "TurnContextTest.hpp" + +#include + +#include "session/FencedText.hpp" +#include "session/Session.hpp" +#include "session/TurnContext.hpp" +#include "session/TurnContextBuilder.hpp" +#include "session/TurnContextPorts.hpp" + +namespace QodeAssist { + +namespace { + +class FakeProjectContext : public Session::IProjectContextPort +{ +public: + explicit FakeProjectContext(Session::ProjectInfo info) + : m_info(std::move(info)) + {} + + Session::ProjectInfo projectInfo() const override { return m_info; } + +private: + Session::ProjectInfo m_info; +}; + +class FakeSkillsContext : public Session::ISkillsContextPort +{ +public: + FakeSkillsContext(QString alwaysOn, QString catalog, QList skills) + : m_alwaysOn(std::move(alwaysOn)) + , m_catalog(std::move(catalog)) + , m_skills(std::move(skills)) + {} + + QString alwaysOnBodies() const override { return m_alwaysOn; } + QString catalogText() const override { return m_catalog; } + + std::optional findSkill(const QString &name) const override + { + for (const Session::InvokedSkill &skill : m_skills) { + if (skill.name == name) + return skill; + } + return std::nullopt; + } + +private: + QString m_alwaysOn; + QString m_catalog; + QList m_skills; +}; + +Session::ProjectInfo sampleProject() +{ + Session::ProjectInfo info; + info.available = true; + info.displayName = "QodeAssist"; + info.sourceRoot = "/src/QodeAssist"; + info.buildDirectory = "/src/QodeAssist/build"; + return info; +} + +} // namespace + +void TurnContextTest::testTurnContextCollectsPartsFromPorts() +{ + FakeProjectContext projectPort(sampleProject()); + FakeSkillsContext skillsPort( + "# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}}); + + Session::TurnContextRequest request; + request.message = "please /review this"; + request.basePrompt = "You are a helpful assistant."; + + const Session::TurnContext context + = Session::TurnContextBuilder(projectPort, &skillsPort).build(request); + + QCOMPARE(context.basePrompt, QString("You are a helpful assistant.")); + QVERIFY(context.project.available); + QCOMPARE(context.project.displayName, QString("QodeAssist")); + QCOMPARE(context.alwaysOnSkills, QString("# Always on")); + QCOMPARE(context.skillsCatalog, QString("# Skills catalog")); + QCOMPARE(context.invokedSkills.size(), 1); + QCOMPARE(context.invokedSkills.at(0).name, QString("review")); +} + +void TurnContextTest::testTurnContextSkipsWhatTheBackendDoesNotNeed() +{ + FakeProjectContext projectPort(sampleProject()); + FakeSkillsContext skillsPort( + "# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}}); + + Session::TurnContextRequest request; + request.message = "please /review this"; + request.needs = Session::TurnContextNeeds{false}; + + const Session::TurnContext context + = Session::TurnContextBuilder(projectPort, &skillsPort).build(request); + + QVERIFY(context.alwaysOnSkills.isEmpty()); + QVERIFY(context.skillsCatalog.isEmpty()); + QVERIFY(context.invokedSkills.isEmpty()); +} + +void TurnContextTest::testFencedFileBlockOutgrowsBackticksInContent() +{ + const QString plain = Session::fencedFileBlock("notes.txt", "hello"); + QCOMPARE(plain, QString("File: notes.txt\n```\nhello\n```")); + + const QString fenced = Session::fencedFileBlock("readme.md", "text\n```\ncode\n```\nmore"); + QVERIFY(fenced.startsWith("File: readme.md\n````\n")); + QVERIFY(fenced.endsWith("\n````")); + QVERIFY(fenced.contains("```\ncode\n```")); + + const QString nested = Session::fencedFileBlock("deep.md", "`````"); + QVERIFY(nested.contains("``````")); +} + +void TurnContextTest::testTurnContextSkillCommandScanning() +{ + FakeProjectContext projectPort({}); + FakeSkillsContext skillsPort( + QString(), + QString(), + {Session::InvokedSkill{"review", "Review body"}, + Session::InvokedSkill{"docs", "Docs body"}, + Session::InvokedSkill{"blank", QString()}}); + + Session::TurnContextRequest request; + request.message = "/review then /docs then /review again /blank /unknown and mid/word"; + + const Session::TurnContext context + = Session::TurnContextBuilder(projectPort, &skillsPort).build(request); + + QCOMPARE(context.invokedSkills.size(), 2); + QCOMPARE(context.invokedSkills.at(0).name, QString("review")); + QCOMPARE(context.invokedSkills.at(1).name, QString("docs")); +} + +void TurnContextTest::testTurnContextWithoutSkillsPort() +{ + FakeProjectContext projectPort(sampleProject()); + + Session::TurnContextRequest request; + request.message = "/review this"; + request.basePrompt = "base"; + + const Session::TurnContext context + = Session::TurnContextBuilder(projectPort, nullptr).build(request); + + QVERIFY(context.alwaysOnSkills.isEmpty()); + QVERIFY(context.skillsCatalog.isEmpty()); + QVERIFY(context.invokedSkills.isEmpty()); +} + +void TurnContextTest::testSystemPromptRenderingWithProject() +{ + Session::TurnContext context; + context.basePrompt = "You are helpful."; + context.project = sampleProject(); + context.alwaysOnSkills = "# Always on"; + context.skillsCatalog = "# Catalog"; + context.invokedSkills = {Session::InvokedSkill{"review", "Review body"}}; + + const QString expected + = "You are helpful." + "\n# Active project: QodeAssist" + "\n# Project source root: /src/QodeAssist" + "\n# All new source files, headers, QML and CMake edits MUST be created or modified " + "under this directory. Use absolute paths rooted here, or project-relative paths." + "\n# Build output directory (compiler artifacts only — do NOT create or edit source " + "files here): /src/QodeAssist/build" + "\n\n# Always on" + "\n\n# Catalog" + "\n\n# Invoked Skill: review\n\nReview body"; + + QCOMPARE(Session::renderSystemPrompt(context), expected); +} + +void TurnContextTest::testSystemPromptRenderingWithoutProject() +{ + Session::TurnContext context; + context.basePrompt = "You are helpful."; + + QCOMPARE( + Session::renderSystemPrompt(context), + QString("You are helpful.\n# No active project in IDE")); +} + +} // namespace QodeAssist diff --git a/tests/TurnContextTest.hpp b/tests/TurnContextTest.hpp new file mode 100644 index 0000000..87e4424 --- /dev/null +++ b/tests/TurnContextTest.hpp @@ -0,0 +1,25 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +namespace QodeAssist { + +class TurnContextTest final : public QObject +{ + Q_OBJECT + +private slots: + void testTurnContextCollectsPartsFromPorts(); + void testTurnContextSkipsWhatTheBackendDoesNotNeed(); + void testFencedFileBlockOutgrowsBackticksInContent(); + void testTurnContextSkillCommandScanning(); + void testTurnContextWithoutSkillsPort(); + void testSystemPromptRenderingWithProject(); + void testSystemPromptRenderingWithoutProject(); +}; + +} // namespace QodeAssist