refactor: IProjectScanner port; ContextManager QtC-free

This commit is contained in:
Petr Mironychev
2026-06-11 15:21:02 +02:00
parent f36173d932
commit 69672deb45
12 changed files with 435 additions and 115 deletions

View File

@@ -1103,11 +1103,7 @@ void ChatRootView::setRecentFilePath(const QString &filePath)
bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath) bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath)
{ {
auto project = ProjectExplorer::ProjectManager::projectForFile(filePath); if (m_clientInterface->contextManager()->shouldIgnore(filePath.toFSPathString())) {
if (project
&& m_clientInterface->contextManager()
->ignoreManager()
->shouldIgnore(filePath.toFSPathString(), project)) {
LOG_MESSAGE(QString("Ignoring file for attachment due to .qodeassistignore: %1") LOG_MESSAGE(QString("Ignoring file for attachment due to .qodeassistignore: %1")
.arg(filePath.toFSPathString())); .arg(filePath.toFSPathString()));
return true; return true;

View File

@@ -273,9 +273,8 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor)
return; return;
if (m_llmClient->contextManager() if (m_llmClient->contextManager()->shouldIgnore(
->ignoreManager() editor->textDocument()->filePath().toUrlishString())) {
->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) {
LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1")
.arg(editor->textDocument()->filePath().toUrlishString())); .arg(editor->textDocument()->filePath().toUrlishString()));
return; return;
@@ -319,9 +318,8 @@ void QodeAssistClient::requestQuickRefactor(
if (!isEnabled(project)) if (!isEnabled(project))
return; return;
if (m_llmClient->contextManager() if (m_llmClient->contextManager()->shouldIgnore(
->ignoreManager() editor->textDocument()->filePath().toUrlishString())) {
->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) {
LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1")
.arg(editor->textDocument()->filePath().toUrlishString())); .arg(editor->textDocument()->filePath().toUrlishString()));
return; return;

View File

@@ -2,6 +2,8 @@ add_library(Context STATIC
DocumentContextReader.hpp DocumentContextReader.cpp DocumentContextReader.hpp DocumentContextReader.cpp
ChangesManager.h ChangesManager.cpp ChangesManager.h ChangesManager.cpp
ContextManager.hpp ContextManager.cpp ContextManager.hpp ContextManager.cpp
IProjectScanner.hpp
ProjectScannerQtCreator.hpp ProjectScannerQtCreator.cpp
ContentFile.hpp ContentFile.hpp
DocumentReaderQtCreator.hpp DocumentReaderQtCreator.hpp
IDocumentReader.hpp IDocumentReader.hpp

View File

@@ -6,25 +6,24 @@
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonObject>
#include <QTextStream> #include <QTextStream>
#include "settings/GeneralSettings.hpp"
#include <coreplugin/editormanager/editormanager.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <projectexplorer/projectnodes.h>
#include <texteditor/textdocument.h>
#include "Logger.hpp" #include "Logger.hpp"
#include "ProjectScannerQtCreator.hpp"
namespace QodeAssist::Context { namespace QodeAssist::Context {
ContextManager::ContextManager(QObject *parent) ContextManager::ContextManager(QObject *parent)
: QObject(parent) : ContextManager(std::make_unique<ProjectScannerQtCreator>(), parent)
, m_ignoreManager(new IgnoreManager(this))
{} {}
ContextManager::ContextManager(std::unique_ptr<IProjectScanner> scanner, QObject *parent)
: QObject(parent)
, m_scanner(std::move(scanner))
{}
ContextManager::~ContextManager() = default;
QString ContextManager::readFile(const QString &filePath) const QString ContextManager::readFile(const QString &filePath) const
{ {
QFile file(filePath); QFile file(filePath);
@@ -45,9 +44,7 @@ QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths)
{ {
QList<ContentFile> files; QList<ContentFile> files;
for (const QString &path : filePaths) { for (const QString &path : filePaths) {
auto project = ProjectExplorer::ProjectManager::projectForFile( if (m_scanner->shouldIgnore(path)) {
Utils::FilePath::fromString(path));
if (project && m_ignoreManager->shouldIgnore(path, project)) {
LOG_MESSAGE(QString("Ignoring file in context due to .qodeassistignore: %1").arg(path)); LOG_MESSAGE(QString("Ignoring file in context due to .qodeassistignore: %1").arg(path));
continue; continue;
} }
@@ -58,27 +55,6 @@ QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths)
return files; return files;
} }
QStringList ContextManager::getProjectSourceFiles(ProjectExplorer::Project *project) const
{
QStringList sourceFiles;
if (!project)
return sourceFiles;
auto projectNode = project->rootProjectNode();
if (!projectNode)
return sourceFiles;
projectNode->forEachNode(
[&sourceFiles, this](ProjectExplorer::FileNode *fileNode) {
if (fileNode /*&& shouldProcessFile(fileNode->filePath().toString())*/) {
sourceFiles.append(fileNode->filePath().toUrlishString());
}
},
nullptr);
return sourceFiles;
}
ContentFile ContextManager::createContentFile(const QString &filePath) const ContentFile ContextManager::createContentFile(const QString &filePath) const
{ {
ContentFile contentFile; ContentFile contentFile;
@@ -101,72 +77,25 @@ ProgrammingLanguage ContextManager::getDocumentLanguage(const DocumentInfo &docu
bool ContextManager::isSpecifyCompletion(const DocumentInfo &documentInfo) const bool ContextManager::isSpecifyCompletion(const DocumentInfo &documentInfo) const
{ {
Q_UNUSED(documentInfo) Q_UNUSED(documentInfo)
// Language-specific completion presets were replaced by agent match rules.
return false; return false;
} }
QList<QPair<QString, QString>> ContextManager::openedFiles(const QStringList excludeFiles) const QString ContextManager::openedFilesContext(const QStringList &excludeFiles) const
{
auto documents = Core::DocumentModel::openedDocuments();
QList<QPair<QString, QString>> files;
for (const auto *document : std::as_const(documents)) {
auto textDocument = qobject_cast<const TextEditor::TextDocument *>(document);
if (!textDocument)
continue;
auto filePath = textDocument->filePath().toUrlishString();
auto project = ProjectExplorer::ProjectManager::projectForFile(textDocument->filePath());
if (project && m_ignoreManager->shouldIgnore(filePath, project)) {
LOG_MESSAGE(
QString("Ignoring file in context due to .qodeassistignore: %1").arg(filePath));
continue;
}
if (!excludeFiles.contains(filePath)) {
files.append({filePath, textDocument->plainText()});
}
}
return files;
}
QString ContextManager::openedFilesContext(const QStringList excludeFiles)
{ {
QString context = "User files context:\n"; QString context = "User files context:\n";
auto documents = Core::DocumentModel::openedDocuments(); for (const auto &file : m_scanner->openedTextFiles(excludeFiles)) {
context += QString("File: %1\n").arg(file.filePath);
for (const auto *document : documents) { context += file.content;
auto textDocument = qobject_cast<const TextEditor::TextDocument *>(document);
if (!textDocument)
continue;
auto filePath = textDocument->filePath().toUrlishString();
if (excludeFiles.contains(filePath))
continue;
auto project = ProjectExplorer::ProjectManager::projectForFile(textDocument->filePath());
if (project && m_ignoreManager->shouldIgnore(filePath, project)) {
LOG_MESSAGE(
QString("Ignoring file in context due to .qodeassistignore: %1").arg(filePath));
continue;
}
context += QString("File: %1\n").arg(filePath);
context += textDocument->plainText();
context += "\n"; context += "\n";
} }
return context; return context;
} }
IgnoreManager *ContextManager::ignoreManager() const bool ContextManager::shouldIgnore(const QString &filePath) const
{ {
return m_ignoreManager; return m_scanner->shouldIgnore(filePath);
} }
} // namespace QodeAssist::Context } // namespace QodeAssist::Context

View File

@@ -4,18 +4,16 @@
#pragma once #pragma once
#include <memory>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include "ContentFile.hpp" #include "ContentFile.hpp"
#include "IContextManager.hpp" #include "IContextManager.hpp"
#include "IgnoreManager.hpp" #include "IProjectScanner.hpp"
#include "ProgrammingLanguage.hpp" #include "ProgrammingLanguage.hpp"
namespace ProjectExplorer {
class Project;
}
namespace QodeAssist::Context { namespace QodeAssist::Context {
class ContextManager : public QObject, public IContextManager class ContextManager : public QObject, public IContextManager
@@ -24,22 +22,22 @@ class ContextManager : public QObject, public IContextManager
public: public:
explicit ContextManager(QObject *parent = nullptr); explicit ContextManager(QObject *parent = nullptr);
~ContextManager() override = default; ContextManager(std::unique_ptr<IProjectScanner> scanner, QObject *parent = nullptr);
~ContextManager() override;
QString readFile(const QString &filePath) const override; QString readFile(const QString &filePath) const override;
QList<ContentFile> getContentFiles(const QStringList &filePaths) const override; QList<ContentFile> getContentFiles(const QStringList &filePaths) const override;
QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const override;
ContentFile createContentFile(const QString &filePath) const override; ContentFile createContentFile(const QString &filePath) const override;
ProgrammingLanguage getDocumentLanguage(const DocumentInfo &documentInfo) const override; ProgrammingLanguage getDocumentLanguage(const DocumentInfo &documentInfo) const override;
bool isSpecifyCompletion(const DocumentInfo &documentInfo) const override; bool isSpecifyCompletion(const DocumentInfo &documentInfo) const override;
QList<QPair<QString, QString>> openedFiles(const QStringList excludeFiles = QStringList{}) const;
QString openedFilesContext(const QStringList excludeFiles = QStringList{});
IgnoreManager *ignoreManager() const; QString openedFilesContext(const QStringList &excludeFiles = QStringList{}) const;
bool shouldIgnore(const QString &filePath) const;
private: private:
IgnoreManager *m_ignoreManager; std::unique_ptr<IProjectScanner> m_scanner;
}; };
} // namespace QodeAssist::Context } // namespace QodeAssist::Context

View File

@@ -11,10 +11,6 @@
#include "IDocumentReader.hpp" #include "IDocumentReader.hpp"
#include "ProgrammingLanguage.hpp" #include "ProgrammingLanguage.hpp"
namespace ProjectExplorer {
class Project;
}
namespace QodeAssist::Context { namespace QodeAssist::Context {
class IContextManager class IContextManager
@@ -24,7 +20,6 @@ public:
virtual QString readFile(const QString &filePath) const = 0; virtual QString readFile(const QString &filePath) const = 0;
virtual QList<ContentFile> getContentFiles(const QStringList &filePaths) const = 0; virtual QList<ContentFile> getContentFiles(const QStringList &filePaths) const = 0;
virtual QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const = 0;
virtual ContentFile createContentFile(const QString &filePath) const = 0; virtual ContentFile createContentFile(const QString &filePath) const = 0;
virtual ProgrammingLanguage getDocumentLanguage(const DocumentInfo &documentInfo) const = 0; virtual ProgrammingLanguage getDocumentLanguage(const DocumentInfo &documentInfo) const = 0;

View File

@@ -0,0 +1,28 @@
// 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
#include <QList>
#include <QString>
#include <QStringList>
namespace QodeAssist::Context {
struct OpenedTextFile
{
QString filePath;
QString content;
};
class IProjectScanner
{
public:
virtual ~IProjectScanner() = default;
virtual QList<OpenedTextFile> openedTextFiles(const QStringList &excludeFiles = {}) const = 0;
virtual bool shouldIgnore(const QString &filePath) const = 0;
};
} // namespace QodeAssist::Context

View File

@@ -0,0 +1,53 @@
// 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 "ProjectScannerQtCreator.hpp"
#include <coreplugin/editormanager/editormanager.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <texteditor/textdocument.h>
#include <utils/filepath.h>
#include "IgnoreManager.hpp"
namespace QodeAssist::Context {
ProjectScannerQtCreator::ProjectScannerQtCreator()
: m_ignoreManager(std::make_unique<IgnoreManager>())
{}
ProjectScannerQtCreator::~ProjectScannerQtCreator() = default;
QList<OpenedTextFile> ProjectScannerQtCreator::openedTextFiles(
const QStringList &excludeFiles) const
{
QList<OpenedTextFile> files;
const auto documents = Core::DocumentModel::openedDocuments();
for (const auto *document : documents) {
const auto *textDocument = qobject_cast<const TextEditor::TextDocument *>(document);
if (!textDocument)
continue;
const QString filePath = textDocument->filePath().toUrlishString();
if (excludeFiles.contains(filePath))
continue;
if (shouldIgnore(filePath))
continue;
files.append({filePath, textDocument->plainText()});
}
return files;
}
bool ProjectScannerQtCreator::shouldIgnore(const QString &filePath) const
{
auto *project = ProjectExplorer::ProjectManager::projectForFile(
Utils::FilePath::fromString(filePath));
return project && m_ignoreManager->shouldIgnore(filePath, project);
}
} // namespace QodeAssist::Context

View File

@@ -0,0 +1,28 @@
// 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
#include <memory>
#include "IProjectScanner.hpp"
namespace QodeAssist::Context {
class IgnoreManager;
class ProjectScannerQtCreator : public IProjectScanner
{
public:
ProjectScannerQtCreator();
~ProjectScannerQtCreator() override;
QList<OpenedTextFile> openedTextFiles(const QStringList &excludeFiles = {}) const override;
bool shouldIgnore(const QString &filePath) const override;
private:
std::unique_ptr<IgnoreManager> m_ignoreManager;
};
} // namespace QodeAssist::Context

View File

@@ -4,6 +4,8 @@ add_executable(QodeAssistTest
CodeHandlerTest.cpp CodeHandlerTest.cpp
DocumentContextReaderTest.cpp DocumentContextReaderTest.cpp
LLMSuggestionTest.cpp LLMSuggestionTest.cpp
JsonPromptTemplateTest.cpp
ResponseRouterTest.cpp
# LLMClientInterfaceTests.cpp # LLMClientInterfaceTests.cpp
unittest_main.cpp unittest_main.cpp
) )
@@ -18,6 +20,9 @@ target_link_libraries(QodeAssistTest PRIVATE
Context Context
Common Common
LLMQore LLMQore
Templates
Agents
Session
) )
target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR}) target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR})

View File

@@ -0,0 +1,129 @@
// 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 <gtest/gtest.h>
#include <QJsonArray>
#include <QJsonObject>
#include <AgentConfig.hpp>
#include <ContextData.hpp>
#include <JsonPromptTemplate.hpp>
using QodeAssist::AgentConfig;
using QodeAssist::Templates::ContextData;
using QodeAssist::Templates::JsonPromptTemplate;
namespace {
AgentConfig makeConfig(const QJsonObject &body)
{
AgentConfig cfg;
cfg.name = QStringLiteral("test-agent");
cfg.body = body;
return cfg;
}
const QString kUserMessages
= QStringLiteral("[ { \"role\": \"user\", \"content\": {{ tojson(ctx.prefix) }} } ]");
const QString kSystemField = QStringLiteral(
"{% if existsIn(ctx, \"system_prompt\") %}{{ tojson(ctx.system_prompt) }}{% endif %}");
} // namespace
TEST(JsonPromptTemplateTest, RendersJinjaSplicesAndKeepsLiterals)
{
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
{"max_tokens", 128},
{"temperature", 0.5},
{"stream", true},
{"messages", kUserMessages},
}));
ASSERT_NE(tmpl, nullptr);
ContextData ctx;
ctx.prefix = QStringLiteral("hello world");
QJsonObject request;
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
EXPECT_EQ(request.value("max_tokens").toInt(), 128);
EXPECT_DOUBLE_EQ(request.value("temperature").toDouble(), 0.5);
EXPECT_TRUE(request.value("stream").toBool());
const QJsonArray messages = request.value("messages").toArray();
ASSERT_EQ(messages.size(), 1);
EXPECT_EQ(
messages.at(0).toObject().value("content").toString(), QStringLiteral("hello world"));
}
TEST(JsonPromptTemplateTest, DropsKeyWhenJinjaRendersEmpty)
{
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
{"system", kSystemField},
{"messages", kUserMessages},
}));
ASSERT_NE(tmpl, nullptr);
ContextData ctx;
ctx.prefix = QStringLiteral("hi");
QJsonObject request;
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
EXPECT_FALSE(request.contains(QStringLiteral("system")));
EXPECT_TRUE(request.contains(QStringLiteral("messages")));
}
TEST(JsonPromptTemplateTest, RendersSystemPromptWhenPresent)
{
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
{"system", kSystemField},
{"messages", kUserMessages},
}));
ASSERT_NE(tmpl, nullptr);
ContextData ctx;
ctx.prefix = QStringLiteral("hi");
ctx.systemPrompt = QStringLiteral("You are a helpful assistant.");
QJsonObject request;
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
EXPECT_EQ(
request.value("system").toString(), QStringLiteral("You are a helpful assistant."));
}
TEST(JsonPromptTemplateTest, PreservesNestedLiteralObjects)
{
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
{"thinking", QJsonObject{{"type", "adaptive"}, {"budget", 8192}}},
{"messages", kUserMessages},
}));
ASSERT_NE(tmpl, nullptr);
ContextData ctx;
ctx.prefix = QStringLiteral("x");
QJsonObject request;
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
const QJsonObject thinking = request.value("thinking").toObject();
EXPECT_EQ(thinking.value("type").toString(), QStringLiteral("adaptive"));
EXPECT_EQ(thinking.value("budget").toInt(), 8192);
}
TEST(JsonPromptTemplateTest, RejectsBodyThatRendersInvalidJsonAtLoad)
{
QString error;
auto tmpl = JsonPromptTemplate::fromConfig(
makeConfig(QJsonObject{
{"messages", QStringLiteral("[ {{ tojson(ctx.prefix) }}")},
}),
&error);
EXPECT_EQ(tmpl, nullptr);
EXPECT_FALSE(error.isEmpty());
}

159
test/ResponseRouterTest.cpp Normal file
View File

@@ -0,0 +1,159 @@
// 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 <gtest/gtest.h>
#include <optional>
#include <QFuture>
#include <QHash>
#include <QJsonObject>
#include <QNetworkRequest>
#include <QVector>
#include <LLMQore/BaseClient.hpp>
#include <LLMQore/ToolResult.hpp>
#include <ConversationHistory.hpp>
#include <ErrorInfo.hpp>
#include <Message.hpp>
#include <ResponseEvent.hpp>
#include <ResponseRouter.hpp>
using namespace QodeAssist;
namespace {
class FakeClient : public LLMQore::BaseClient
{
public:
using LLMQore::BaseClient::BaseClient;
void fireChunk(const QString &id, const QString &chunk) { emit chunkReceived(id, chunk); }
void fireThinking(const QString &id, const QString &thinking, const QString &signature)
{
emit thinkingBlockReceived(id, thinking, signature);
}
void fireToolStarted(
const QString &id, const QString &toolId, const QString &name, const QJsonObject &args)
{
emit toolStarted(id, toolId, name, args);
}
void fireToolResult(
const QString &id, const QString &toolId, const QString &name, const QString &result)
{
emit toolResultReady(id, toolId, name, result);
}
void fireFinalized(const QString &id, const LLMQore::CompletionInfo &info)
{
emit requestFinalized(id, info);
}
void fireFailed(const QString &id, const QString &error) { emit requestFailed(id, error); }
protected:
LLMQore::RequestID sendMessage(
const QJsonObject &, const QString &, LLMQore::RequestMode) override
{
return {};
}
LLMQore::RequestID ask(const QString &, LLMQore::RequestMode) override { return {}; }
QFuture<QList<QString>> listModels() override { return {}; }
LLMQore::ToolSchemaFormat toolSchemaFormat() const override
{
return LLMQore::ToolSchemaFormat::Claude;
}
void processData(const LLMQore::RequestID &, const QByteArray &) override {}
void processBufferedResponse(const LLMQore::RequestID &, const QByteArray &) override {}
QNetworkRequest prepareNetworkRequest(const QUrl &) const override { return {}; }
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 {};
}
};
} // namespace
TEST(ResponseRouterTest, BuildsAssistantTurnAndEmitsEvents)
{
FakeClient client;
ConversationHistory history;
ResponseRouter router(&client, &history);
QVector<ResponseEvent::Kind> kinds;
QObject::connect(&router, &ResponseRouter::event, &router, [&kinds](const ResponseEvent &ev) {
kinds.append(ev.kind());
});
const QString id = QStringLiteral("req-1");
router.beginRequest(id);
client.fireThinking(id, QStringLiteral("pondering"), QStringLiteral("sig"));
client.fireChunk(id, QStringLiteral("Hello"));
client.fireChunk(id, QStringLiteral(" world"));
client.fireToolStarted(
id, QStringLiteral("t1"), QStringLiteral("read_file"), QJsonObject{{"path", "a.txt"}});
client.fireToolResult(
id, QStringLiteral("t1"), QStringLiteral("read_file"), QStringLiteral("contents"));
LLMQore::CompletionInfo info;
info.stopReason = QStringLiteral("end_turn");
info.usage = LLMQore::TokenUsage{12, 34, 0, 0};
client.fireFinalized(id, info);
ASSERT_EQ(history.size(), 2);
const Message &assistant = history.messages()[0];
EXPECT_EQ(assistant.role(), Message::Role::Assistant);
EXPECT_EQ(assistant.id(), id);
EXPECT_EQ(assistant.text(), QStringLiteral("Hello world"));
EXPECT_TRUE(assistant.hasToolUse());
const Message &toolResult = history.messages()[1];
EXPECT_EQ(toolResult.role(), Message::Role::User);
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::ThinkingDelta));
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::TextDelta));
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::ToolResult));
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::Usage));
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::MessageStop));
}
TEST(ResponseRouterTest, CategorizesAuthError)
{
FakeClient client;
ConversationHistory history;
ResponseRouter router(&client, &history);
std::optional<ResponseEvents::Error> captured;
QObject::connect(&router, &ResponseRouter::event, &router, [&captured](const ResponseEvent &ev) {
if (ev.kind() == ResponseEvent::Kind::Error)
captured = *ev.as<ResponseEvents::Error>();
});
router.beginRequest(QStringLiteral("req-2"));
client.fireFailed(
QStringLiteral("req-2"), QStringLiteral("HTTP 401 Unauthorized: invalid api key"));
ASSERT_TRUE(captured.has_value());
EXPECT_EQ(captured->category, ErrorCategory::Auth);
}
TEST(ResponseRouterTest, IgnoresEventsForInactiveRequest)
{
FakeClient client;
ConversationHistory history;
ResponseRouter router(&client, &history);
router.beginRequest(QStringLiteral("req-3"));
client.fireChunk(QStringLiteral("OTHER"), QStringLiteral("ignored"));
EXPECT_TRUE(history.isEmpty());
}