feat: add support acp in common chat (#369)

This commit is contained in:
Petr Mironychev
2026-07-21 13:10:00 +02:00
committed by GitHub
parent af85efb554
commit 442263a6a7
158 changed files with 14270 additions and 4118 deletions

324
tests/FakeAcpAgent.hpp Normal file
View File

@@ -0,0 +1,324 @@
// 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 <QJsonArray>
#include <QJsonObject>
#include <QStringList>
#include <QTimer>
#include <LLMQore/AcpTypes.hpp>
#include <LLMQore/RpcTransport.hpp>
namespace QodeAssist {
class FakeAcpAgent : public LLMQore::Rpc::Transport
{
Q_OBJECT
public:
explicit FakeAcpAgent(QObject *parent = nullptr)
: LLMQore::Rpc::Transport(parent)
{}
void start() override
{
m_open = true;
setState(State::Connected);
}
void stop() override
{
if (!m_open)
return;
m_open = false;
setState(State::Disconnected);
emit closed();
}
bool isOpen() const override { return m_open; }
void send(const QJsonObject &message) override
{
const QString method = message.value("method").toString();
if (method.isEmpty() && message.contains("id")) {
m_permissionOutcomes.append(
message.value("result").toObject().value("outcome").toObject());
if (!m_hangOnPrompt)
finishPrompt();
return;
}
m_methods.append(method);
if (method == QLatin1String("initialize")) {
m_clientInfo = message.value("params").toObject().value("clientInfo").toObject();
reply(message, initializeResult());
} else if (method == QLatin1String("session/new")) {
m_newSessionCwd = message.value("params").toObject().value("cwd").toString();
m_offeredMcpServers = message.value("params").toObject().value("mcpServers").toArray();
if (m_hangOnNewSession)
return;
if (m_requireAuthentication && !m_authenticated) {
replyError(message, -32000, QStringLiteral("Authentication required"));
} else {
if (!m_commandsBeforeSessionReply.isEmpty()) {
postUpdate(
QJsonObject{
{"sessionUpdate", QStringLiteral("available_commands_update")},
{"availableCommands", m_commandsBeforeSessionReply}});
}
reply(message, QJsonObject{{"sessionId", m_sessionId}});
}
} else if (method == QLatin1String("session/load")) {
m_loadedSessionId = message.value("params").toObject().value("sessionId").toString();
if (m_loadSessionError.isEmpty())
reply(message, QJsonObject{{"sessionId", m_loadedSessionId}});
else
replyError(message, -32603, m_loadSessionError);
} else if (method == QLatin1String("authenticate")) {
m_authenticated = true;
m_authMethodUsed = message.value("params").toObject().value("methodId").toString();
reply(message, QJsonObject{});
} else if (method == QLatin1String("session/prompt")) {
m_prompts.append(message.value("params").toObject().value("prompt").toArray());
respondToPrompt(message);
} else if (method == QLatin1String("session/cancel")) {
m_cancelled = true;
}
}
void setRequireAuthentication(bool require) { m_requireAuthentication = require; }
void setSupportsLoadSession(bool supports) { m_loadSession = supports; }
void setSupportsEmbeddedContext(bool supports) { m_embeddedContext = supports; }
void setSupportsImages(bool supports) { m_images = supports; }
void setPromptFailure(const QString &error) { m_promptError = error; }
void setHangOnPrompt(bool hang) { m_hangOnPrompt = hang; }
void setReplyChunks(const QStringList &chunks) { m_chunks = chunks; }
void setThoughtChunks(const QStringList &chunks) { m_thoughts = chunks; }
void setPermissionRequest(const QJsonObject &toolCall, const QJsonArray &options)
{
m_permissionToolCall = toolCall;
m_permissionOptions = options;
}
void setFinishPromptWithoutWaiting(bool finish) { m_finishPromptWithoutWaiting = finish; }
void setToolCallUpdates(const QList<QJsonObject> &updates) { m_toolCalls = updates; }
void setPlanUpdates(const QList<QJsonArray> &plans) { m_plans = plans; }
void setAvailableCommandUpdates(const QList<QJsonArray> &updates)
{
m_availableCommandUpdates = updates;
}
void setCommandsBeforeSessionReply(const QJsonArray &commands)
{
m_commandsBeforeSessionReply = commands;
}
void setPromptUsage(const QJsonObject &usage) { m_promptUsage = usage; }
void setContextGauge(const QJsonObject &gauge) { m_contextGauge = gauge; }
void setSuggestedTitle(const QString &title) { m_suggestedTitle = title; }
void setLoadSessionError(const QString &error) { m_loadSessionError = error; }
void setHangOnNewSession(bool hang) { m_hangOnNewSession = hang; }
void setSupportsHttpMcp(bool supports) { m_httpMcp = supports; }
QJsonArray offeredMcpServers() const { return m_offeredMcpServers; }
QString loadedSessionId() const { return m_loadedSessionId; }
QList<QJsonObject> permissionOutcomes() const { return m_permissionOutcomes; }
QStringList methods() const { return m_methods; }
QString newSessionCwd() const { return m_newSessionCwd; }
QString authMethodUsed() const { return m_authMethodUsed; }
QJsonObject clientInfo() const { return m_clientInfo; }
QList<QJsonArray> prompts() const { return m_prompts; }
bool wasCancelled() const { return m_cancelled; }
private:
QJsonObject initializeResult() const
{
QJsonObject capabilities{
{"loadSession", m_loadSession},
{"promptCapabilities",
QJsonObject{{"image", m_images}, {"embeddedContext", m_embeddedContext}}},
{"mcpCapabilities", QJsonObject{{"http", m_httpMcp}, {"sse", false}}}};
QJsonObject result{{"protocolVersion", 1}, {"agentCapabilities", capabilities}};
result.insert(
"agentInfo",
QJsonObject{{"name", QStringLiteral("Fake")}, {"version", QStringLiteral("1.0")}});
if (m_requireAuthentication) {
result.insert(
"authMethods",
QJsonArray{
QJsonObject{{"id", QStringLiteral("login")}, {"name", QStringLiteral("Login")}}});
}
return result;
}
void respondToPrompt(const QJsonObject &request)
{
for (const QString &chunk : m_thoughts)
notifyUpdate(QStringLiteral("agent_thought_chunk"), chunk);
for (const QString &chunk : m_chunks)
notifyUpdate(QStringLiteral("agent_message_chunk"), chunk);
for (int i = 0; i < m_toolCalls.size(); ++i) {
QJsonObject update = m_toolCalls.at(i);
update.insert(
"sessionUpdate",
i == 0 ? QStringLiteral("tool_call") : QStringLiteral("tool_call_update"));
postUpdate(update);
}
for (const QJsonArray &entries : m_plans)
postUpdate(QJsonObject{{"sessionUpdate", QStringLiteral("plan")}, {"entries", entries}});
for (const QJsonArray &commands : m_availableCommandUpdates) {
postUpdate(
QJsonObject{
{"sessionUpdate", QStringLiteral("available_commands_update")},
{"availableCommands", commands}});
}
if (!m_contextGauge.isEmpty()) {
QJsonObject update = m_contextGauge;
update.insert("sessionUpdate", QStringLiteral("usage_update"));
postUpdate(update);
}
m_activePrompt = request;
if (!m_permissionToolCall.isEmpty()) {
askPermission();
if (!m_finishPromptWithoutWaiting)
return;
}
if (m_hangOnPrompt)
return;
finishPrompt();
}
void askPermission()
{
post(
QJsonObject{
{"jsonrpc", QStringLiteral("2.0")},
{"id", ++m_outgoingId},
{"method", QStringLiteral("session/request_permission")},
{"params",
QJsonObject{
{"sessionId", m_sessionId},
{"toolCall", m_permissionToolCall},
{"options", m_permissionOptions}}}});
}
void finishPrompt()
{
if (m_activePrompt.isEmpty())
return;
const QJsonObject request = m_activePrompt;
m_activePrompt = QJsonObject();
if (!m_promptError.isEmpty()) {
replyError(request, -32603, m_promptError);
return;
}
QJsonObject result{{"stopReason", QStringLiteral("end_turn")}};
if (!m_promptUsage.isEmpty())
result.insert("usage", m_promptUsage);
reply(request, result);
if (!m_suggestedTitle.isEmpty()) {
postUpdate(
QJsonObject{
{"sessionUpdate", QStringLiteral("session_info_update")},
{"title", m_suggestedTitle}});
}
}
void postUpdate(const QJsonObject &update)
{
post(
QJsonObject{
{"jsonrpc", QStringLiteral("2.0")},
{"method", QStringLiteral("session/update")},
{"params", QJsonObject{{"sessionId", m_sessionId}, {"update", update}}}});
}
void notifyUpdate(const QString &kind, const QString &text)
{
postUpdate(
QJsonObject{
{"sessionUpdate", kind},
{"content", QJsonObject{{"type", QStringLiteral("text")}, {"text", text}}}});
}
void reply(const QJsonObject &request, const QJsonObject &result)
{
post(
QJsonObject{
{"jsonrpc", QStringLiteral("2.0")},
{"id", request.value("id")},
{"result", result}});
}
void replyError(const QJsonObject &request, int code, const QString &message)
{
post(
QJsonObject{
{"jsonrpc", QStringLiteral("2.0")},
{"id", request.value("id")},
{"error", QJsonObject{{"code", code}, {"message", message}}}});
}
void post(const QJsonObject &message)
{
QTimer::singleShot(0, this, [this, message]() { emit messageReceived(message); });
}
bool m_open = false;
bool m_hangOnPrompt = false;
bool m_loadSession = false;
bool m_embeddedContext = false;
bool m_images = false;
bool m_requireAuthentication = false;
bool m_authenticated = false;
bool m_cancelled = false;
bool m_finishPromptWithoutWaiting = false;
bool m_hangOnNewSession = false;
bool m_httpMcp = false;
QJsonArray m_offeredMcpServers;
QString m_sessionId = QStringLiteral("fake-session");
QString m_promptError;
QString m_newSessionCwd;
QString m_authMethodUsed;
QJsonObject m_clientInfo;
int m_outgoingId = 1000;
QStringList m_chunks;
QStringList m_thoughts;
QStringList m_methods;
QList<QJsonArray> m_prompts;
QJsonObject m_activePrompt;
QJsonObject m_permissionToolCall;
QJsonArray m_permissionOptions;
QList<QJsonObject> m_permissionOutcomes;
QList<QJsonObject> m_toolCalls;
QList<QJsonArray> m_plans;
QList<QJsonArray> m_availableCommandUpdates;
QJsonArray m_commandsBeforeSessionReply;
QJsonObject m_promptUsage;
QJsonObject m_contextGauge;
QString m_suggestedTitle;
QString m_loadSessionError;
QString m_loadedSessionId;
};
} // namespace QodeAssist

184
tests/FakeLlmProvider.hpp Normal file
View File

@@ -0,0 +1,184 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QJsonObject>
#include <QNetworkRequest>
#include <LLMQore/BaseClient.hpp>
#include <LLMQore/BaseTool.hpp>
#include <LLMQore/ToolResult.hpp>
#include "providers/Provider.hpp"
#include "templates/IPromptProvider.hpp"
#include "templates/PromptTemplate.hpp"
namespace QodeAssist {
class FakeChatClient : public ::LLMQore::BaseClient
{
Q_OBJECT
public:
using BaseClient::BaseClient;
::LLMQore::RequestID sendMessage(
const QJsonObject &payload,
const QString &endpoint,
::LLMQore::RequestMode mode) override
{
Q_UNUSED(endpoint)
Q_UNUSED(mode)
lastPayload = payload;
return QStringLiteral("fake-req-%1").arg(++requestCounter);
}
::LLMQore::RequestID ask(const QString &prompt, ::LLMQore::RequestMode mode) override
{
Q_UNUSED(prompt)
Q_UNUSED(mode)
return {};
}
QFuture<QList<QString>> listModels(const QString &endpoint) override
{
Q_UNUSED(endpoint)
return QtFuture::makeReadyValueFuture(QList<QString>{});
}
QJsonObject lastPayload;
int requestCounter = 0;
protected:
::LLMQore::ToolSchemaFormat toolSchemaFormat() const override
{
return ::LLMQore::ToolSchemaFormat::OpenAI;
}
void processData(const ::LLMQore::RequestID &, const QByteArray &) override {}
void processBufferedResponse(const ::LLMQore::RequestID &, const QByteArray &) override {}
QNetworkRequest prepareNetworkRequest(const QUrl &url) const override
{
return QNetworkRequest(url);
}
::LLMQore::BaseMessage *messageForRequest(const ::LLMQore::RequestID &) const override
{
return nullptr;
}
void cleanupDerivedData(const ::LLMQore::RequestID &) override {}
QJsonObject buildContinuationPayload(
const QJsonObject &,
::LLMQore::BaseMessage *,
const QHash<QString, ::LLMQore::ToolResult> &) override
{
return {};
}
};
class FakeMutatingTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
using BaseTool::BaseTool;
QString id() const override { return QStringLiteral("fake_write"); }
QString displayName() const override { return QStringLiteral("Fake Write"); }
QString description() const override { return QStringLiteral("Writes something"); }
QJsonObject parametersSchema() const override { return {}; }
QFuture<::LLMQore::ToolResult> executeAsync(const QJsonObject &input) override
{
Q_UNUSED(input)
++executions;
return QtFuture::makeReadyValueFuture(::LLMQore::ToolResult::text(QStringLiteral("done")));
}
int executions = 0;
};
class FakeChatTemplate : public Templates::PromptTemplate
{
public:
Templates::TemplateType type() const override { return Templates::TemplateType::Chat; }
QString name() const override { return QStringLiteral("FakeChatTemplate"); }
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; }
bool supportsToolHistory() const override { return true; }
};
class FakeChatPromptProvider : public Templates::IPromptProvider
{
public:
Templates::PromptTemplate *getTemplateByName(const QString &) const override
{
return &m_template;
}
QStringList templatesNames() const override { return {m_template.name()}; }
QStringList getTemplatesForProvider(Providers::ProviderID) const override
{
return {m_template.name()};
}
private:
mutable FakeChatTemplate m_template;
};
class FakeLlmProvider : public Providers::Provider
{
Q_OBJECT
public:
explicit FakeLlmProvider(QObject *parent = nullptr)
: Providers::Provider(parent)
, m_client(new FakeChatClient(this))
{}
QString name() const override { return QStringLiteral("FakeLLM"); }
QString url() const override { return {}; }
void prepareRequest(
QJsonObject &,
Templates::PromptTemplate *,
LLMCore::ContextData context,
LLMCore::RequestType,
bool,
bool) override
{
lastContext = context;
}
LLMCore::ContextData lastContext;
QFuture<QList<QString>> getInstalledModels(const QString &) override
{
return QtFuture::makeReadyValueFuture(QList<QString>{});
}
Providers::ProviderID providerID() const override { return Providers::ProviderID::Any; }
Providers::ProviderCapabilities capabilities() const override
{
return Providers::ProviderCapability::Tools;
}
::LLMQore::BaseClient *client() const override { return m_client; }
QString apiKey() const override { return {}; }
FakeChatClient *fakeClient() const { return m_client; }
private:
FakeChatClient *m_client = nullptr;
};
} // namespace QodeAssist

File diff suppressed because it is too large Load Diff

View File

@@ -98,11 +98,19 @@ private slots:
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();
@@ -112,13 +120,138 @@ private slots:
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 testLinkedFilesHeaderSurvivesUnreadablePaths();
void testSystemPromptRenderingWithProject();
void testSystemPromptRenderingWithoutProject();
void testAgentRegistryParsesEveryDistributionKind();
void testAgentRegistryReportsUnusableEntries();
void testAgentRegistryParsesSingleAgentUserFile();
void testAgentCatalogAppliesMergePriority();
void testAgentCatalogUserFileMakesBinaryAgentLaunchable();
void testAgentLaunchConfigUsesRunnerConventions();
void testAgentSearchPathsSplitting();
void testAgentExtraSearchPathsReachTheChildEnvironment();
void testAgentForwardedVariablesReachTheChildEnvironment();
void testBundledAgentSnapshotParses();
void testAcpBackendStreamsTextAndThinking();
void testAcpBackendStartsSessionInWorkingDirectory();
void testAcpBackendAuthenticatesWhenTheAgentAsksForIt();
void testAcpBackendReportsPromptFailure();
void testAcpBackendCancelInterruptsTheTurn();
void testAcpBackendSendsAttachmentsAsEmbeddedResources();
void testAcpBackendInlinesAttachmentsWithoutEmbeddedContextSupport();
void testAcpBackendReportsAttachmentsItCannotLoad();
void testAcpBackendSendsImagesWhenTheAgentAcceptsThem();
void testAcpBackendNamesImagesTheAgentCannotAccept();
void testAcpBackendFencesAttachmentsThatContainFences();
void testAcpBackendTruncatesOversizedAttachments();
void testAcpBackendRejectsAnEmptyTurn();
void testAcpBackendSendsAttachmentsWithoutAnyMessageText();
void testAcpBackendRaisesAgentPermissionRequests();
void testAcpBackendSendsThePermissionAnswerToTheAgent();
void testAcpBackendCancelsOutstandingPermissionRequests();
void testTurnLedgerTracksTheActiveTurn();
void testTurnLedgerDrainsPermissionsOnTurnEnd();
void testLlmBackendStreamsThroughAFakeClient();
void testRenderHistoryKeepsToolExchangePairs();
void testLlmBackendIgnoresEventsFromOtherRequests();
void testLlmBackendPermissionRoundTrip();
void testLlmBackendDrainsPermissionsWhenTheTurnEnds();
void testCoordinatorSwitchConfirmAndCancelRoundTrip();
void testCoordinatorQuarantinesAndRestoresAgentBindings();
void testCoordinatorRefusesIntentsWhileReadOnly();
void testCoordinatorDefersTheSendForAutoCompress();
void testCoordinatorGatesTheSummaryHandover();
void testCoordinatorShrinkContextRoutesByBackendKind();
void testAcpBackendTracksAgentSlashCommands();
void testEarlyAgentCommandsSurviveEstablishment();
void testAgentCommandInvocationSendsPlainPrompt();
void testFileMentionSelectionFollowsChatToolsSetting();
void testChatModelExposesSessionRowsDirectly();
void testBlockCodecRejectsForgedMarkers();
void testFileEditParsingRequiresTheMarkerAtTheStart();
void testPermissionOptionsCarryAllowsAcrossTheSeam();
void testChatFileStoreRoundTripsStoredContent();
void testLegacyChatFileLoadsThroughTheFileStore();
void testPickerObservesTheSharedAgentCatalog();
void testAcpBackendSeedsAHandoverSummaryOnce();
void testAcpBackendDropsAHandoverSummaryAfterACancelledTurn();
void testAcpBackendDropsAHandoverSummaryWithTheConversation();
void testToolSafetyDefaultsToMutating();
void testToolsManagerWithoutAGateRunsImmediately();
void testToolsManagerGateCanDeclineAToolCall();
void testToolsManagerGateCanAllowAToolCall();
void testToolsManagerGateResumesTheQueueAfterADenial();
void testKnowledgeServerExposesOnlyReadOnlyTools();
void testKnowledgeServerBindsLoopbackOnAnEphemeralPort();
void testAcpBackendOffersKnowledgeToHttpCapableAgents();
void testAcpBackendWithholdsKnowledgeFromAgentsWithoutHttpMcp();
void testAcpBackendStopsTheKnowledgeServerWithTheSession();
void testAcpBackendSurvivesAKnowledgeServerThatWillNotStart();
void testAcpBackendResumesAPersistedSession();
void testAcpBackendReportsAnUnresumableSession();
void testAcpBackendRefusesToResumeWithoutAgentSupport();
void testAcpBackendStartsFreshAfterAFailedResume();
void testAgentBindingKeepsTheResumeTargetBeforeTheFirstTurn();
void testAcpBackendCarriesTheLiveSessionIntoAResume();
void testAcpBackendEstablishesTheSessionOnlyOnce();
void testAgentBindingRejectsMalformedRecords();
void testMalformedAgentBindingLeavesTheChatUnbound();
void testAgentBindingRoundTripsThroughTheChatFile();
void testChatFileWithoutAnAgentLoadsUnbound();
void testAcpBackendMapsToolCallLifecycle();
void testAcpBackendMapsPlanUpdates();
void testAcpBackendReportsUsageFromThePromptResult();
void testAcpBackendIgnoresTheCumulativeContextGauge();
void testAcpBackendForwardsTheAgentSuggestedTitle();
void testAcpBackendDeclinesAmbiguousPermissionOptions();
void testAcpBackendClampsOversizedPermissionText();
void testAcpBackendCancelsPermissionsWhenTheTurnCompletes();
void testAcpBackendCancelsPermissionRequestsWhenTheTurnFails();
private:
Context::DocumentContextReader createReader(const QString &text);
QSharedPointer<Settings::CodeCompletionSettings> createSettingsForWholeFile();