Files
QodeAssist/tests/AgentCatalogTest.cpp
2026-07-22 10:41:35 +02:00

351 lines
14 KiB
C++

// 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 <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTest>
#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