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