// 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