diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index f759293..a635c54 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -1103,11 +1103,7 @@ void ChatRootView::setRecentFilePath(const QString &filePath) bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath) { - auto project = ProjectExplorer::ProjectManager::projectForFile(filePath); - if (project - && m_clientInterface->contextManager() - ->ignoreManager() - ->shouldIgnore(filePath.toFSPathString(), project)) { + if (m_clientInterface->contextManager()->shouldIgnore(filePath.toFSPathString())) { LOG_MESSAGE(QString("Ignoring file for attachment due to .qodeassistignore: %1") .arg(filePath.toFSPathString())); return true; diff --git a/QodeAssistClient.cpp b/QodeAssistClient.cpp index 34a1616..0b45f1a 100644 --- a/QodeAssistClient.cpp +++ b/QodeAssistClient.cpp @@ -273,9 +273,8 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor) return; - if (m_llmClient->contextManager() - ->ignoreManager() - ->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) { + if (m_llmClient->contextManager()->shouldIgnore( + editor->textDocument()->filePath().toUrlishString())) { LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") .arg(editor->textDocument()->filePath().toUrlishString())); return; @@ -319,9 +318,8 @@ void QodeAssistClient::requestQuickRefactor( if (!isEnabled(project)) return; - if (m_llmClient->contextManager() - ->ignoreManager() - ->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) { + if (m_llmClient->contextManager()->shouldIgnore( + editor->textDocument()->filePath().toUrlishString())) { LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") .arg(editor->textDocument()->filePath().toUrlishString())); return; diff --git a/context/CMakeLists.txt b/context/CMakeLists.txt index 3c94d99..81ec9ad 100644 --- a/context/CMakeLists.txt +++ b/context/CMakeLists.txt @@ -2,6 +2,8 @@ add_library(Context STATIC DocumentContextReader.hpp DocumentContextReader.cpp ChangesManager.h ChangesManager.cpp ContextManager.hpp ContextManager.cpp + IProjectScanner.hpp + ProjectScannerQtCreator.hpp ProjectScannerQtCreator.cpp ContentFile.hpp DocumentReaderQtCreator.hpp IDocumentReader.hpp diff --git a/context/ContextManager.cpp b/context/ContextManager.cpp index 3e1d36c..6c160b0 100644 --- a/context/ContextManager.cpp +++ b/context/ContextManager.cpp @@ -6,25 +6,24 @@ #include #include -#include #include -#include "settings/GeneralSettings.hpp" -#include -#include -#include -#include -#include - #include "Logger.hpp" +#include "ProjectScannerQtCreator.hpp" namespace QodeAssist::Context { ContextManager::ContextManager(QObject *parent) - : QObject(parent) - , m_ignoreManager(new IgnoreManager(this)) + : ContextManager(std::make_unique(), parent) {} +ContextManager::ContextManager(std::unique_ptr scanner, QObject *parent) + : QObject(parent) + , m_scanner(std::move(scanner)) +{} + +ContextManager::~ContextManager() = default; + QString ContextManager::readFile(const QString &filePath) const { QFile file(filePath); @@ -37,7 +36,7 @@ QString ContextManager::readFile(const QString &filePath) const QTextStream in(&file); QString content = in.readAll(); file.close(); - + return content; } @@ -45,9 +44,7 @@ QList ContextManager::getContentFiles(const QStringList &filePaths) { QList files; for (const QString &path : filePaths) { - auto project = ProjectExplorer::ProjectManager::projectForFile( - Utils::FilePath::fromString(path)); - if (project && m_ignoreManager->shouldIgnore(path, project)) { + if (m_scanner->shouldIgnore(path)) { LOG_MESSAGE(QString("Ignoring file in context due to .qodeassistignore: %1").arg(path)); continue; } @@ -58,27 +55,6 @@ QList ContextManager::getContentFiles(const QStringList &filePaths) 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 contentFile; @@ -101,72 +77,25 @@ ProgrammingLanguage ContextManager::getDocumentLanguage(const DocumentInfo &docu bool ContextManager::isSpecifyCompletion(const DocumentInfo &documentInfo) const { Q_UNUSED(documentInfo) - // Language-specific completion presets were replaced by agent match rules. return false; } -QList> ContextManager::openedFiles(const QStringList excludeFiles) const -{ - auto documents = Core::DocumentModel::openedDocuments(); - - QList> files; - - for (const auto *document : std::as_const(documents)) { - auto textDocument = qobject_cast(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 ContextManager::openedFilesContext(const QStringList &excludeFiles) const { QString context = "User files context:\n"; - auto documents = Core::DocumentModel::openedDocuments(); - - for (const auto *document : documents) { - auto textDocument = qobject_cast(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(); - + for (const auto &file : m_scanner->openedTextFiles(excludeFiles)) { + context += QString("File: %1\n").arg(file.filePath); + context += file.content; context += "\n"; } return context; } -IgnoreManager *ContextManager::ignoreManager() const +bool ContextManager::shouldIgnore(const QString &filePath) const { - return m_ignoreManager; + return m_scanner->shouldIgnore(filePath); } } // namespace QodeAssist::Context diff --git a/context/ContextManager.hpp b/context/ContextManager.hpp index 1213afe..a3a5533 100644 --- a/context/ContextManager.hpp +++ b/context/ContextManager.hpp @@ -4,18 +4,16 @@ #pragma once +#include + #include #include #include "ContentFile.hpp" #include "IContextManager.hpp" -#include "IgnoreManager.hpp" +#include "IProjectScanner.hpp" #include "ProgrammingLanguage.hpp" -namespace ProjectExplorer { -class Project; -} - namespace QodeAssist::Context { class ContextManager : public QObject, public IContextManager @@ -24,22 +22,22 @@ class ContextManager : public QObject, public IContextManager public: explicit ContextManager(QObject *parent = nullptr); - ~ContextManager() override = default; + ContextManager(std::unique_ptr scanner, QObject *parent = nullptr); + ~ContextManager() override; QString readFile(const QString &filePath) const override; QList getContentFiles(const QStringList &filePaths) const override; - QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const override; ContentFile createContentFile(const QString &filePath) const override; ProgrammingLanguage getDocumentLanguage(const DocumentInfo &documentInfo) const override; bool isSpecifyCompletion(const DocumentInfo &documentInfo) const override; - QList> 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: - IgnoreManager *m_ignoreManager; + std::unique_ptr m_scanner; }; } // namespace QodeAssist::Context diff --git a/context/IContextManager.hpp b/context/IContextManager.hpp index 2f0a592..4ecebed 100644 --- a/context/IContextManager.hpp +++ b/context/IContextManager.hpp @@ -11,10 +11,6 @@ #include "IDocumentReader.hpp" #include "ProgrammingLanguage.hpp" -namespace ProjectExplorer { -class Project; -} - namespace QodeAssist::Context { class IContextManager @@ -24,7 +20,6 @@ public: virtual QString readFile(const QString &filePath) const = 0; virtual QList getContentFiles(const QStringList &filePaths) const = 0; - virtual QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const = 0; virtual ContentFile createContentFile(const QString &filePath) const = 0; virtual ProgrammingLanguage getDocumentLanguage(const DocumentInfo &documentInfo) const = 0; diff --git a/context/IProjectScanner.hpp b/context/IProjectScanner.hpp new file mode 100644 index 0000000..0b21078 --- /dev/null +++ b/context/IProjectScanner.hpp @@ -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 +#include +#include + +namespace QodeAssist::Context { + +struct OpenedTextFile +{ + QString filePath; + QString content; +}; + +class IProjectScanner +{ +public: + virtual ~IProjectScanner() = default; + + virtual QList openedTextFiles(const QStringList &excludeFiles = {}) const = 0; + virtual bool shouldIgnore(const QString &filePath) const = 0; +}; + +} // namespace QodeAssist::Context diff --git a/context/ProjectScannerQtCreator.cpp b/context/ProjectScannerQtCreator.cpp new file mode 100644 index 0000000..eae22c9 --- /dev/null +++ b/context/ProjectScannerQtCreator.cpp @@ -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 +#include +#include +#include +#include + +#include "IgnoreManager.hpp" + +namespace QodeAssist::Context { + +ProjectScannerQtCreator::ProjectScannerQtCreator() + : m_ignoreManager(std::make_unique()) +{} + +ProjectScannerQtCreator::~ProjectScannerQtCreator() = default; + +QList ProjectScannerQtCreator::openedTextFiles( + const QStringList &excludeFiles) const +{ + QList files; + + const auto documents = Core::DocumentModel::openedDocuments(); + for (const auto *document : documents) { + const auto *textDocument = qobject_cast(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 diff --git a/context/ProjectScannerQtCreator.hpp b/context/ProjectScannerQtCreator.hpp new file mode 100644 index 0000000..66f0c6a --- /dev/null +++ b/context/ProjectScannerQtCreator.hpp @@ -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 + +#include "IProjectScanner.hpp" + +namespace QodeAssist::Context { + +class IgnoreManager; + +class ProjectScannerQtCreator : public IProjectScanner +{ +public: + ProjectScannerQtCreator(); + ~ProjectScannerQtCreator() override; + + QList openedTextFiles(const QStringList &excludeFiles = {}) const override; + bool shouldIgnore(const QString &filePath) const override; + +private: + std::unique_ptr m_ignoreManager; +}; + +} // namespace QodeAssist::Context diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e6d2fd5..9e7ded4 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -4,6 +4,8 @@ add_executable(QodeAssistTest CodeHandlerTest.cpp DocumentContextReaderTest.cpp LLMSuggestionTest.cpp + JsonPromptTemplateTest.cpp + ResponseRouterTest.cpp # LLMClientInterfaceTests.cpp unittest_main.cpp ) @@ -18,6 +20,9 @@ target_link_libraries(QodeAssistTest PRIVATE Context Common LLMQore + Templates + Agents + Session ) target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR}) diff --git a/test/JsonPromptTemplateTest.cpp b/test/JsonPromptTemplateTest.cpp new file mode 100644 index 0000000..14d22bb --- /dev/null +++ b/test/JsonPromptTemplateTest.cpp @@ -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 + +#include +#include + +#include +#include +#include + +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()); +} diff --git a/test/ResponseRouterTest.cpp b/test/ResponseRouterTest.cpp new file mode 100644 index 0000000..133aaab --- /dev/null +++ b/test/ResponseRouterTest.cpp @@ -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 + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +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> 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 &) override + { + return {}; + } +}; + +} // namespace + +TEST(ResponseRouterTest, BuildsAssistantTurnAndEmitsEvents) +{ + FakeClient client; + ConversationHistory history; + ResponseRouter router(&client, &history); + + QVector 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 captured; + QObject::connect(&router, &ResponseRouter::event, &router, [&captured](const ResponseEvent &ev) { + if (ev.kind() == ResponseEvent::Kind::Error) + captured = *ev.as(); + }); + + 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()); +}