From af85efb5541d3a3eb4d51311cb28cc3934c92cde Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:45:04 +0200 Subject: [PATCH] refactor: Decoupling chat architecture (#367) * refactor: Migrate tests to Qt Creator plugin framework and update llmqore transports * refactor: Move conversation history into IDE-free session library * refactor: Extract turn context assembly behind injected ports * build: Add RunQodeAssistTests target for the plugin test suite * refactor: Route the chat through a Session and ChatBackend seam --- .github/workflows/build_cmake.yml | 42 +- CMakeLists.txt | 34 +- sources/CMakeLists.txt | 1 + sources/ChatView/CMakeLists.txt | 6 +- sources/ChatView/ChatCompressor.cpp | 66 +- sources/ChatView/ChatCompressor.hpp | 9 +- sources/ChatView/ChatController.cpp | 319 ++++ sources/ChatView/ChatController.hpp | 79 + sources/ChatView/ChatHistoryBridge.cpp | 107 ++ sources/ChatView/ChatHistoryBridge.hpp | 37 + sources/ChatView/ChatHistoryStore.cpp | 39 +- sources/ChatView/ChatHistoryStore.hpp | 11 +- sources/ChatView/ChatModel.cpp | 548 +------ sources/ChatView/ChatModel.hpp | 65 +- sources/ChatView/ChatRootView.cpp | 68 +- sources/ChatView/ChatRootView.hpp | 5 +- sources/ChatView/ChatSerializer.cpp | 228 +-- sources/ChatView/ChatSerializer.hpp | 23 +- sources/ChatView/ClientInterface.cpp | 745 --------- sources/ChatView/ClientInterface.hpp | 102 -- sources/ChatView/FileEditController.cpp | 62 +- sources/ChatView/FileEditController.hpp | 7 +- sources/ChatView/InputTokenCounter.cpp | 74 +- sources/ChatView/InputTokenCounter.hpp | 28 +- sources/ChatView/LlmChatBackend.cpp | 420 +++++ sources/ChatView/LlmChatBackend.hpp | 69 + sources/ChatView/TurnContextAdapters.cpp | 106 ++ sources/ChatView/TurnContextAdapters.hpp | 64 + sources/ChatView/qml/RootItem.qml | 2 +- sources/Session/CMakeLists.txt | 23 - sources/Session/ConversationHistory.cpp | 83 - sources/Session/ConversationHistory.hpp | 50 - sources/Session/LLMRequest.hpp | 33 - sources/Session/Message.cpp | 31 - sources/Session/Message.hpp | 74 - sources/Session/MessageSerializer.cpp | 213 --- sources/Session/MessageSerializer.hpp | 21 - sources/Session/PluginBlocks.hpp | 115 -- sources/Session/ResponseEvent.hpp | 170 -- sources/Session/ResponseRouter.cpp | 164 -- sources/Session/ResponseRouter.hpp | 65 - sources/Session/Session.cpp | 387 ----- sources/Session/Session.hpp | 106 -- sources/Session/SessionManager.cpp | 106 -- sources/Session/SessionManager.hpp | 53 - sources/Session/SystemPromptBuilder.cpp | 72 - sources/Session/SystemPromptBuilder.hpp | 37 - sources/external/llmqore | 2 +- sources/mcp/McpServerConnection.cpp | 13 +- sources/mcp/McpServerConnection.hpp | 9 +- sources/plugin/qodeassist.cpp | 8 + sources/session/CMakeLists.txt | 22 + sources/session/ChatBackend.hpp | 28 + sources/session/ContentBlock.hpp | 110 ++ sources/session/ConversationHistory.cpp | 47 + sources/session/ConversationHistory.hpp | 39 + sources/session/FileEditPayload.cpp | 49 + sources/session/FileEditPayload.hpp | 18 + sources/session/HistoryProjection.cpp | 244 +++ sources/session/HistoryProjection.hpp | 51 + sources/session/HistorySerializer.cpp | 290 ++++ sources/session/HistorySerializer.hpp | 27 + sources/session/Message.hpp | 55 + sources/session/Session.cpp | 427 +++++ sources/session/Session.hpp | 78 + sources/session/SessionEvent.cpp | 14 + sources/session/SessionEvent.hpp | 100 ++ sources/session/TurnContext.cpp | 56 + sources/session/TurnContext.hpp | 57 + sources/session/TurnContextBuilder.cpp | 58 + sources/session/TurnContextBuilder.hpp | 46 + sources/session/TurnContextPorts.hpp | 53 + sources/session/TurnRequest.hpp | 33 + sources/tools/ReadOriginalHistoryTool.cpp | 39 +- tests/CMakeLists.txt | 30 - tests/ClaudeCacheControlTest.cpp | 182 --- tests/CodeHandlerTest.cpp | 218 --- tests/DocumentContextReaderTest.cpp | 393 ----- tests/LLMSuggestionTest.cpp | 112 -- tests/QodeAssistTest.cpp | 1746 +++++++++++++++++++++ tests/QodeAssistTest.hpp | 130 ++ tests/TestUtils.hpp | 65 - tests/unittest_main.cpp | 23 - 83 files changed, 5421 insertions(+), 4620 deletions(-) create mode 100644 sources/ChatView/ChatController.cpp create mode 100644 sources/ChatView/ChatController.hpp create mode 100644 sources/ChatView/ChatHistoryBridge.cpp create mode 100644 sources/ChatView/ChatHistoryBridge.hpp delete mode 100644 sources/ChatView/ClientInterface.cpp delete mode 100644 sources/ChatView/ClientInterface.hpp create mode 100644 sources/ChatView/LlmChatBackend.cpp create mode 100644 sources/ChatView/LlmChatBackend.hpp create mode 100644 sources/ChatView/TurnContextAdapters.cpp create mode 100644 sources/ChatView/TurnContextAdapters.hpp delete mode 100644 sources/Session/CMakeLists.txt delete mode 100644 sources/Session/ConversationHistory.cpp delete mode 100644 sources/Session/ConversationHistory.hpp delete mode 100644 sources/Session/LLMRequest.hpp delete mode 100644 sources/Session/Message.cpp delete mode 100644 sources/Session/Message.hpp delete mode 100644 sources/Session/MessageSerializer.cpp delete mode 100644 sources/Session/MessageSerializer.hpp delete mode 100644 sources/Session/PluginBlocks.hpp delete mode 100644 sources/Session/ResponseEvent.hpp delete mode 100644 sources/Session/ResponseRouter.cpp delete mode 100644 sources/Session/ResponseRouter.hpp delete mode 100644 sources/Session/Session.cpp delete mode 100644 sources/Session/Session.hpp delete mode 100644 sources/Session/SessionManager.cpp delete mode 100644 sources/Session/SessionManager.hpp delete mode 100644 sources/Session/SystemPromptBuilder.cpp delete mode 100644 sources/Session/SystemPromptBuilder.hpp create mode 100644 sources/session/CMakeLists.txt create mode 100644 sources/session/ChatBackend.hpp create mode 100644 sources/session/ContentBlock.hpp create mode 100644 sources/session/ConversationHistory.cpp create mode 100644 sources/session/ConversationHistory.hpp create mode 100644 sources/session/FileEditPayload.cpp create mode 100644 sources/session/FileEditPayload.hpp create mode 100644 sources/session/HistoryProjection.cpp create mode 100644 sources/session/HistoryProjection.hpp create mode 100644 sources/session/HistorySerializer.cpp create mode 100644 sources/session/HistorySerializer.hpp create mode 100644 sources/session/Message.hpp create mode 100644 sources/session/Session.cpp create mode 100644 sources/session/Session.hpp create mode 100644 sources/session/SessionEvent.cpp create mode 100644 sources/session/SessionEvent.hpp create mode 100644 sources/session/TurnContext.cpp create mode 100644 sources/session/TurnContext.hpp create mode 100644 sources/session/TurnContextBuilder.cpp create mode 100644 sources/session/TurnContextBuilder.hpp create mode 100644 sources/session/TurnContextPorts.hpp create mode 100644 sources/session/TurnRequest.hpp delete mode 100644 tests/CMakeLists.txt delete mode 100644 tests/ClaudeCacheControlTest.cpp delete mode 100644 tests/CodeHandlerTest.cpp delete mode 100644 tests/DocumentContextReaderTest.cpp delete mode 100644 tests/LLMSuggestionTest.cpp create mode 100644 tests/QodeAssistTest.cpp create mode 100644 tests/QodeAssistTest.hpp delete mode 100644 tests/TestUtils.hpp delete mode 100644 tests/unittest_main.cpp diff --git a/.github/workflows/build_cmake.yml b/.github/workflows/build_cmake.yml index 7bf7b2f..0593105 100644 --- a/.github/workflows/build_cmake.yml +++ b/.github/workflows/build_cmake.yml @@ -94,8 +94,8 @@ jobs: execute_process( COMMAND sudo apt install # build dependencies - libgl1-mesa-dev libgtest-dev libgmock-dev - # runtime dependencies for tests (Qt is downloaded outside package manager, + libgl1-mesa-dev + # runtime dependencies (Qt is downloaded outside package manager, # thus minimal dependencies must be installed explicitly) libsecret-1-0 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-render0 libxcb-shape0 libxcb-shm0 libxcb-sync1 libxcb-xfixes0 libxcb-xkb1 libxkbcommon-x11-0 xvfb @@ -276,17 +276,45 @@ jobs: message(FATAL_ERROR "Build failed") endif() + - name: Build with tests + if: startsWith(matrix.config.os, 'ubuntu') + shell: cmake -P {0} + run: | + set(ENV{CC} ${{ matrix.config.cc }}) + set(ENV{CXX} ${{ matrix.config.cxx }}) + set(ENV{NINJA_STATUS} "[%f/%t %o/sec] ") + + set(build_plugin_py "scripts/build_plugin.py") + foreach(dir "share/qtcreator/scripts" "Qt Creator.sdk/share/qtcreator/scripts" "Qt Creator.app/Contents/Resources/scripts" "Contents/Resources/scripts") + if(EXISTS "${{ steps.qt_creator.outputs.qtc_dir }}/${dir}/build_plugin.py") + set(build_plugin_py "${dir}/build_plugin.py") + break() + endif() + endforeach() + + execute_process( + COMMAND python + -u + "${{ steps.qt_creator.outputs.qtc_dir }}/${build_plugin_py}" + --name "$ENV{PLUGIN_NAME}-tests" + --src . + --build build-tests + --qt-path "${{ steps.qt.outputs.qt_dir }}" + --qtc-path "${{ steps.qt_creator.outputs.qtc_dir }}" + --output-path "${{ runner.temp }}" + --add-config=-DWITH_TESTS=ON + RESULT_VARIABLE result + ) + if (NOT result EQUAL 0) + message(FATAL_ERROR "Test build failed") + endif() + - name: Upload uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: path: ./${{ env.PLUGIN_NAME }}-v${{ steps.git.outputs.tag }}-QtC${{ matrix.qt_config.qt_creator_version }}-${{ matrix.config.artifact }}.7z name: ${{ env.PLUGIN_NAME}}-v${{ steps.git.outputs.tag }}-QtC${{ matrix.qt_config.qt_creator_version }}-${{ matrix.config.artifact }}.7z - - name: Run unit tests - if: startsWith(matrix.config.os, 'ubuntu') - run: | - xvfb-run ./build/build/tests/QodeAssistTest - release: if: contains(github.ref, 'tags/v') runs-on: ubuntu-22.04 diff --git a/CMakeLists.txt b/CMakeLists.txt index 00b6b4d..1ff59e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,8 +11,14 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_POSITION_INDEPENDENT_CODE ON) find_package(QtCreator REQUIRED COMPONENTS Core) -find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Svg Test LinguistTools REQUIRED) -find_package(GTest) +find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Svg LinguistTools REQUIRED) +set(QT_VERSION_MAJOR 6) + +option(WITH_TESTS "Builds with tests" NO) + +if(WITH_TESTS) + find_package(Qt6 REQUIRED COMPONENTS Test) +endif() qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES en cs zh_CN zh_TW da de fr hr ja pl ru sl sv uk @@ -37,9 +43,6 @@ add_definitions( ) add_subdirectory(sources) -if(GTest_FOUND) - add_subdirectory(tests) -endif() add_qtc_plugin(QodeAssist PLUGIN_DEPENDS @@ -60,6 +63,7 @@ add_qtc_plugin(QodeAssist QtCreator::CPlusPlus LLMQore Skills + QodeAssistSession QodeAssistChatViewplugin SOURCES .github/workflows/build_cmake.yml @@ -171,6 +175,17 @@ add_qtc_plugin(QodeAssist target_include_directories(QodeAssist PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/sources) +extend_qtc_plugin(QodeAssist + CONDITION WITH_TESTS + DEPENDS Qt::Test + SOURCES + tests/QodeAssistTest.hpp tests/QodeAssistTest.cpp +) + +if(WITH_TESTS) + target_include_directories(QodeAssist PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests) +endif() + get_target_property(QtCreatorCorePath QtCreator::Core LOCATION) find_program(QtCreatorExecutable NAMES @@ -186,6 +201,15 @@ if (QtCreatorExecutable) DEPENDS QodeAssist ) set_target_properties(RunQtCreator PROPERTIES FOLDER "qtc_runnable") + + if (WITH_TESTS) + add_custom_target(RunQodeAssistTests + COMMAND ${QtCreatorExecutable} -pluginpath $ -test QodeAssist + DEPENDS QodeAssist + USES_TERMINAL + ) + set_target_properties(RunQodeAssistTests PROPERTIES FOLDER "qtc_runnable") + endif() endif() #TODO change to TS_OUTPUT_DIRECTORY after removing Qt6.8 diff --git a/sources/CMakeLists.txt b/sources/CMakeLists.txt index 3cf965d..7bba6c0 100644 --- a/sources/CMakeLists.txt +++ b/sources/CMakeLists.txt @@ -3,5 +3,6 @@ add_subdirectory(skills) add_subdirectory(logger) add_subdirectory(settings) add_subdirectory(context) +add_subdirectory(session) add_subdirectory(UIControls) add_subdirectory(ChatView) diff --git a/sources/ChatView/CMakeLists.txt b/sources/ChatView/CMakeLists.txt index a8c66b5..2fae156 100644 --- a/sources/ChatView/CMakeLists.txt +++ b/sources/ChatView/CMakeLists.txt @@ -66,10 +66,13 @@ qt_add_qml_module(QodeAssistChatView ChatWidget.hpp ChatWidget.cpp ChatModel.hpp ChatModel.cpp ChatRootView.hpp ChatRootView.cpp - ClientInterface.hpp ClientInterface.cpp + ChatController.hpp ChatController.cpp + LlmChatBackend.hpp LlmChatBackend.cpp MessagePart.hpp ChatUtils.h ChatUtils.cpp ChatSerializer.hpp ChatSerializer.cpp + ChatHistoryBridge.hpp ChatHistoryBridge.cpp + TurnContextAdapters.hpp TurnContextAdapters.cpp ChatView.hpp ChatView.cpp ChatData.hpp FileItem.hpp FileItem.cpp @@ -94,6 +97,7 @@ target_link_libraries(QodeAssistChatView QtCreator::Utils QodeAssistSettings Context + QodeAssistSession QodeAssistUIControlsplugin QodeAssistLogger LLMQore diff --git a/sources/ChatView/ChatCompressor.cpp b/sources/ChatView/ChatCompressor.cpp index 494374c..666da2f 100644 --- a/sources/ChatView/ChatCompressor.cpp +++ b/sources/ChatView/ChatCompressor.cpp @@ -5,16 +5,16 @@ #include "ChatCompressor.hpp" #include -#include "ChatModel.hpp" #include "GeneralSettings.hpp" #include "templates/PromptTemplateManager.hpp" #include "providers/ProvidersManager.hpp" #include "logger/Logger.hpp" +#include "session/HistoryProjection.hpp" +#include "session/HistorySerializer.hpp" #include #include #include -#include #include #include #include @@ -25,7 +25,8 @@ ChatCompressor::ChatCompressor(QObject *parent) : QObject(parent) {} -void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *chatModel) +void ChatCompressor::startCompression( + const QString &chatFilePath, const Session::ConversationHistory &history) { if (m_isCompressing) { emit compressionFailed(tr("Compression already in progress")); @@ -37,7 +38,8 @@ void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *ch return; } - if (!chatModel || chatModel->rowCount() == 0) { + const QList rows = Session::projectToRows(history); + if (rows.isEmpty()) { emit compressionFailed(tr("Chat is empty, nothing to compress")); return; } @@ -60,7 +62,7 @@ void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *ch } m_isCompressing = true; - m_chatModel = chatModel; + m_rows = rows; m_originalChatPath = chatFilePath; m_accumulatedSummary.clear(); @@ -178,15 +180,14 @@ void ChatCompressor::buildRequestPayload( "Your summaries preserve key information, technical details, and the flow of discussion."); QVector messages; - for (const auto &msg : m_chatModel->getChatHistory()) { - if (msg.role == ChatModel::ChatRole::Tool - || msg.role == ChatModel::ChatRole::FileEdit - || msg.role == ChatModel::ChatRole::Thinking) + for (const Session::MessageRow &row : std::as_const(m_rows)) { + if (row.kind == Session::RowKind::Tool || row.kind == Session::RowKind::FileEdit + || row.kind == Session::RowKind::Thinking) continue; LLMCore::Message apiMessage; - apiMessage.role = (msg.role == ChatModel::ChatRole::User) ? "user" : "assistant"; - apiMessage.content = msg.content; + apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant"; + apiMessage.content = row.content; messages.append(apiMessage); } @@ -204,33 +205,21 @@ void ChatCompressor::buildRequestPayload( bool ChatCompressor::createCompressedChatFile( const QString &sourcePath, const QString &destPath, const QString &summary) { - QFile sourceFile(sourcePath); - if (!sourceFile.open(QIODevice::ReadOnly)) { + if (!QFileInfo(sourcePath).isReadable()) { LOG_MESSAGE(QString("Failed to open source chat file: %1").arg(sourcePath)); return false; } - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(sourceFile.readAll(), &parseError); - sourceFile.close(); + Session::Message summaryMessage; + summaryMessage.role = Session::MessageRole::Assistant; + summaryMessage.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + summaryMessage.blocks.append( + Session::TextBlock{QString("# Chat Summary\n\n%1").arg(summary)}); - if (doc.isNull() || !doc.isObject()) { - LOG_MESSAGE(QString("Invalid JSON in chat file: %1 (Error: %2)") - .arg(sourcePath, parseError.errorString())); - return false; - } + Session::ConversationHistory history; + history.append(summaryMessage); - QJsonObject root = doc.object(); - - QJsonObject summaryMessage; - summaryMessage["role"] = "assistant"; - summaryMessage["content"] = QString("# Chat Summary\n\n%1").arg(summary); - summaryMessage["id"] = QUuid::createUuid().toString(QUuid::WithoutBraces); - summaryMessage["isRedacted"] = false; - summaryMessage["attachments"] = QJsonArray(); - summaryMessage["images"] = QJsonArray(); - - root["messages"] = QJsonArray{summaryMessage}; + QJsonObject root = Session::HistorySerializer::toJson(history); root["compressedFrom"] = sourcePath; root["compressedAt"] = QDateTime::currentDateTime().toString(Qt::ISODate); @@ -243,7 +232,16 @@ bool ChatCompressor::createCompressedChatFile( return false; } - destFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + if (destFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) { + LOG_MESSAGE(QString("Failed to write compressed chat file: %1").arg(destFile.errorString())); + return false; + } + + if (!destFile.flush()) { + LOG_MESSAGE(QString("Failed to flush compressed chat file: %1").arg(destFile.errorString())); + return false; + } + return true; } @@ -288,7 +286,7 @@ void ChatCompressor::cleanupState() m_currentRequestId.clear(); m_originalChatPath.clear(); m_accumulatedSummary.clear(); - m_chatModel = nullptr; + m_rows.clear(); m_provider = nullptr; } diff --git a/sources/ChatView/ChatCompressor.hpp b/sources/ChatView/ChatCompressor.hpp index fbe79a3..8e10092 100644 --- a/sources/ChatView/ChatCompressor.hpp +++ b/sources/ChatView/ChatCompressor.hpp @@ -9,6 +9,8 @@ #include #include +#include "session/HistoryProjection.hpp" + namespace QodeAssist::Providers { class Provider; } // namespace QodeAssist::Providers @@ -19,8 +21,6 @@ class PromptTemplate; namespace QodeAssist::Chat { -class ChatModel; - class ChatCompressor : public QObject { Q_OBJECT @@ -28,7 +28,8 @@ class ChatCompressor : public QObject public: explicit ChatCompressor(QObject *parent = nullptr); - void startCompression(const QString &chatFilePath, ChatModel *chatModel); + void startCompression( + const QString &chatFilePath, const Session::ConversationHistory &history); bool isCompressing() const; void cancelCompression(); @@ -59,7 +60,7 @@ private: QString m_originalChatPath; QString m_accumulatedSummary; Providers::Provider *m_provider = nullptr; - ChatModel *m_chatModel = nullptr; + QList m_rows; QList m_connections; }; diff --git a/sources/ChatView/ChatController.cpp b/sources/ChatView/ChatController.cpp new file mode 100644 index 0000000..cdbb304 --- /dev/null +++ b/sources/ChatView/ChatController.cpp @@ -0,0 +1,319 @@ +// 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 "ChatController.hpp" + +#include +#include +#include + +#include "ChatHistoryBridge.hpp" +#include "ChatSerializer.hpp" +#include "LlmChatBackend.hpp" +#include "TurnContextAdapters.hpp" +#include "context/ChangesManager.h" +#include "context/RulesLoader.hpp" +#include "logger/Logger.hpp" +#include "session/FileEditPayload.hpp" +#include "session/TurnContextBuilder.hpp" +#include "settings/ChatAssistantSettings.hpp" + +namespace QodeAssist::Chat { + +ChatController::ChatController( + ChatModel *chatModel, Templates::IPromptProvider *promptProvider, QObject *parent) + : QObject(parent) + , m_promptProvider(promptProvider) + , m_chatModel(chatModel) + , m_contextManager(new Context::ContextManager(this)) + , m_session(new Session::Session(this)) + , m_backend(new LlmChatBackend(promptProvider, this)) +{ + new ChatHistoryBridge(m_session, chatModel, this); + + m_session->setBackend(m_backend); + + connect(m_session, &Session::Session::turnStarted, this, &ChatController::requestStarted); + connect(m_session, &Session::Session::turnFailed, this, &ChatController::errorOccurred); + + connect(m_session, &Session::Session::turnFinished, this, [this](const QString &turnId) { + QString applyError; + if (!Context::ChangesManager::instance().applyPendingEditsForRequest(turnId, &applyError)) { + LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2") + .arg(turnId, applyError)); + } + emit messageReceivedCompletely(); + }); + + connect(m_session, &Session::Session::usageReceived, this, [this](const Session::Usage &usage) { + emit messageUsageReceived( + usage.promptTokens, + usage.completionTokens, + usage.cachedPromptTokens, + usage.reasoningTokens); + }); + + connect(m_session, &Session::Session::rowsReset, this, [this] { registerHistoricalEdits(); }); + + auto &changes = Context::ChangesManager::instance(); + connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &id) { + m_session->updateFileEditStatus(id, "applied", "Successfully applied"); + }); + connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &id) { + m_session->updateFileEditStatus(id, "rejected", "Rejected by user"); + }); + connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &id) { + m_session->updateFileEditStatus(id, "archived", "Archived (from previous conversation turn)"); + }); +} + +void ChatController::setSkillsManager(Skills::SkillsManager *skillsManager) +{ + m_skillsManager = skillsManager; +} + +Session::Session *ChatController::session() const +{ + return m_session; +} + +void ChatController::sendMessage( + const QString &message, + const QList &attachments, + const QList &linkedFiles, + bool useTools, + bool useThinking) +{ + if (message.trimmed().isEmpty() && attachments.isEmpty()) { + LOG_MESSAGE("Ignoring empty chat message"); + return; + } + + Context::ChangesManager::instance().archiveAllNonArchivedEdits(); + + m_session->sendTurn( + composeUserBlocks(message, attachments), + buildTurnContext(message, linkedFiles), + Session::TurnOptions{useTools, useThinking}); +} + +void ChatController::clearMessages() +{ + m_backend->clearToolSession(m_chatFilePath); + m_session->clear(); +} + +void ChatController::cancelRequest() +{ + m_session->cancel(); +} + +void ChatController::resetToRow(int rowIndex) +{ + m_session->truncateRows(rowIndex); +} + +QList ChatController::composeUserBlocks( + const QString &message, const QList &attachments) +{ + QList imageFiles; + QList textFiles; + + for (const QString &filePath : attachments) { + if (isImageFile(filePath)) + imageFiles.append(filePath); + else + textFiles.append(filePath); + } + + QList blocks{Session::TextBlock{message}}; + + if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) { + for (const auto &file : m_contextManager->getContentFiles(textFiles)) { + QString storedPath; + if (!ChatSerializer::saveContentToStorage( + m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) { + continue; + } + blocks.append(Session::AttachmentBlock{file.filename, storedPath}); + LOG_MESSAGE(QString("Stored text file %1 as %2").arg(file.filename, storedPath)); + } + } else if (!textFiles.isEmpty()) { + LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 text file(s)") + .arg(textFiles.size())); + } + + if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) { + for (const QString &imagePath : imageFiles) { + const QString base64Data = encodeImageToBase64(imagePath); + if (base64Data.isEmpty()) + continue; + + const QFileInfo fileInfo(imagePath); + QString storedPath; + if (!ChatSerializer::saveContentToStorage( + m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) { + continue; + } + + blocks.append( + Session::ImageBlock{fileInfo.fileName(), storedPath, getMediaTypeForImage(imagePath)}); + LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath)); + } + } else if (!imageFiles.isEmpty()) { + LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 image(s)") + .arg(imageFiles.size())); + } + + return blocks; +} + +std::optional ChatController::buildTurnContext( + const QString &message, const QList &linkedFiles) const +{ + auto &chatAssistantSettings = Settings::chatAssistantSettings(); + if (!chatAssistantSettings.useSystemPrompt()) + return std::nullopt; + + Session::TurnContextRequest contextRequest; + contextRequest.message = message; + contextRequest.basePrompt = chatAssistantSettings.systemPrompt(); + contextRequest.linkedFilePaths = linkedFiles; + + const QString lastRoleId = chatAssistantSettings.lastUsedRoleId(); + if (!lastRoleId.isEmpty()) { + const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); + if (!role.id.isEmpty()) + contextRequest.rolePrompt = role.systemPrompt; + } + + auto *project = Context::RulesLoader::getActiveProject(); + + ProjectContextQtCreator projectPort(project); + LinkedFilesQtCreator linkedFilesPort(m_contextManager); + auto skillsPort = makeSkillsContext(m_skillsManager, project); + + const Session::TurnContextBuilder builder(projectPort, skillsPort.get(), linkedFilesPort); + + return builder.build(contextRequest); +} + +void ChatController::recordFileEditStatus( + const QString &editId, const QString &status, const QString &fallbackMessage) +{ + const auto edit = Context::ChangesManager::instance().getFileEdit(editId); + const QString message = edit.statusMessage.isEmpty() ? fallbackMessage : edit.statusMessage; + m_session->updateFileEditStatus(editId, status, message); +} + +void ChatController::registerHistoricalEdits() +{ + const QList rows = m_session->rows(); + + for (const Session::MessageRow &row : rows) { + if (row.kind != Session::RowKind::FileEdit) + continue; + + const auto payload = Session::parseFileEditPayload(row.content); + if (!payload) { + LOG_MESSAGE(QString("Skipping unreadable file edit payload in row %1").arg(row.id)); + continue; + } + + const QString editId = payload->value("edit_id").toString(); + const QString filePath = payload->value("file").toString(); + if (editId.isEmpty() || filePath.isEmpty()) + continue; + + if (!payload->value("old_content").isString() || !payload->value("new_content").isString()) { + LOG_MESSAGE( + QString("Skipping file edit %1: content fields are not strings").arg(editId)); + continue; + } + + Context::ChangesManager::instance().addFileEdit( + editId, + filePath, + payload->value("old_content").toString(), + payload->value("new_content").toString(), + false, + true); + + m_session->updateFileEditStatus(editId, "archived", "Loaded from chat history"); + + LOG_MESSAGE(QString( + "Registered historical file edit: %1 (original status: %2, now: " + "archived)") + .arg(editId, payload->value("status").toString())); + } +} + +Context::ContextManager *ChatController::contextManager() const +{ + return m_contextManager; +} + +bool ChatController::isImageFile(const QString &filePath) const +{ + static const QSet imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}; + + QFileInfo fileInfo(filePath); + QString extension = fileInfo.suffix().toLower(); + + return imageExtensions.contains(extension); +} + +QString ChatController::getMediaTypeForImage(const QString &filePath) const +{ + static const QHash mediaTypes + = {{"png", "image/png"}, + {"jpg", "image/jpeg"}, + {"jpeg", "image/jpeg"}, + {"gif", "image/gif"}, + {"webp", "image/webp"}, + {"bmp", "image/bmp"}, + {"svg", "image/svg+xml"}}; + + QFileInfo fileInfo(filePath); + QString extension = fileInfo.suffix().toLower(); + + if (mediaTypes.contains(extension)) { + return mediaTypes[extension]; + } + + QMimeDatabase mimeDb; + QMimeType mimeType = mimeDb.mimeTypeForFile(filePath); + return mimeType.name(); +} + +QString ChatController::encodeImageToBase64(const QString &filePath) const +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + LOG_MESSAGE(QString("Failed to open image file: %1").arg(filePath)); + return QString(); + } + + QByteArray imageData = file.readAll(); + file.close(); + + return imageData.toBase64(); +} + +void ChatController::setChatFilePath(const QString &filePath) +{ + if (!m_chatFilePath.isEmpty() && m_chatFilePath != filePath) + m_backend->clearToolSession(m_chatFilePath); + + m_chatFilePath = filePath; + m_backend->setChatFilePath(filePath); + m_chatModel->setChatFilePath(filePath); +} + +QString ChatController::chatFilePath() const +{ + return m_chatFilePath; +} + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatController.hpp b/sources/ChatView/ChatController.hpp new file mode 100644 index 0000000..10a1d0c --- /dev/null +++ b/sources/ChatView/ChatController.hpp @@ -0,0 +1,79 @@ +// 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 "ChatModel.hpp" +#include "session/Session.hpp" +#include "templates/IPromptProvider.hpp" +#include + +namespace QodeAssist::Skills { +class SkillsManager; +} + +namespace QodeAssist::Chat { + +class ChatHistoryBridge; +class LlmChatBackend; + +class ChatController : public QObject +{ + Q_OBJECT + +public: + explicit ChatController( + ChatModel *chatModel, Templates::IPromptProvider *promptProvider, QObject *parent = nullptr); + + void setSkillsManager(Skills::SkillsManager *skillsManager); + + void sendMessage( + const QString &message, + const QList &attachments = {}, + const QList &linkedFiles = {}, + bool useTools = false, + bool useThinking = false); + void clearMessages(); + void cancelRequest(); + void resetToRow(int rowIndex); + + Session::Session *session() const; + Context::ContextManager *contextManager() const; + + void setChatFilePath(const QString &filePath); + QString chatFilePath() const; + +signals: + void errorOccurred(const QString &error); + void messageReceivedCompletely(); + void requestStarted(const QString &requestId); + void messageUsageReceived( + int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens); + +private: + QList composeUserBlocks( + const QString &message, const QList &attachments); + std::optional buildTurnContext( + const QString &message, const QList &linkedFiles) const; + void recordFileEditStatus( + const QString &editId, const QString &status, const QString &fallbackMessage); + void registerHistoricalEdits(); + + bool isImageFile(const QString &filePath) const; + QString getMediaTypeForImage(const QString &filePath) const; + QString encodeImageToBase64(const QString &filePath) const; + + Templates::IPromptProvider *m_promptProvider = nullptr; + ChatModel *m_chatModel = nullptr; + Context::ContextManager *m_contextManager = nullptr; + Skills::SkillsManager *m_skillsManager = nullptr; + Session::Session *m_session = nullptr; + LlmChatBackend *m_backend = nullptr; + QString m_chatFilePath; +}; + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatHistoryBridge.cpp b/sources/ChatView/ChatHistoryBridge.cpp new file mode 100644 index 0000000..233bf44 --- /dev/null +++ b/sources/ChatView/ChatHistoryBridge.cpp @@ -0,0 +1,107 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "ChatHistoryBridge.hpp" + +#include "ChatModel.hpp" +#include "session/Session.hpp" + +namespace QodeAssist::Chat { + +namespace { + +ChatModel::ChatRole toChatRole(Session::RowKind kind) +{ + switch (kind) { + case Session::RowKind::System: + return ChatModel::ChatRole::System; + case Session::RowKind::User: + return ChatModel::ChatRole::User; + case Session::RowKind::Assistant: + return ChatModel::ChatRole::Assistant; + case Session::RowKind::Tool: + return ChatModel::ChatRole::Tool; + case Session::RowKind::FileEdit: + return ChatModel::ChatRole::FileEdit; + case Session::RowKind::Thinking: + return ChatModel::ChatRole::Thinking; + } + return ChatModel::ChatRole::Assistant; +} + +ChatModel::Message toChatMessage(const Session::MessageRow &row) +{ + ChatModel::Message message; + message.role = toChatRole(row.kind); + message.content = row.content; + message.id = row.id; + message.isRedacted = row.redacted; + message.signature = row.signature; + message.toolName = row.toolName; + message.toolArguments = row.toolArguments; + message.toolResult = row.toolResult; + + for (const Session::AttachmentBlock &attachment : row.attachments) + message.attachments.append(Context::ContentFile{attachment.fileName, attachment.storedPath}); + + for (const Session::ImageBlock &image : row.images) + message.images.append( + ChatModel::ImageAttachment{image.fileName, image.storedPath, image.mediaType}); + + message.promptTokens = row.usage.promptTokens; + message.completionTokens = row.usage.completionTokens; + message.cachedPromptTokens = row.usage.cachedPromptTokens; + message.reasoningTokens = row.usage.reasoningTokens; + + return message; +} + +QVector toChatMessages(const QList &rows) +{ + QVector messages; + messages.reserve(rows.size()); + for (const Session::MessageRow &row : rows) + messages.append(toChatMessage(row)); + return messages; +} + +} // namespace + +ChatHistoryBridge::ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent) + : QObject(parent) + , m_model(model) +{ + connect(session, &Session::Session::rowsReset, this, &ChatHistoryBridge::onRowsReset); + connect(session, &Session::Session::rowsAppended, this, &ChatHistoryBridge::onRowsAppended); + connect(session, &Session::Session::rowUpdated, this, &ChatHistoryBridge::onRowUpdated); + connect(session, &Session::Session::rowsRemoved, this, &ChatHistoryBridge::onRowsRemoved); + + onRowsReset(session->rows()); +} + +void ChatHistoryBridge::onRowsReset(const QList &rows) +{ + if (m_model) + m_model->resetMessages(toChatMessages(rows)); +} + +void ChatHistoryBridge::onRowsAppended(const QList &rows) +{ + if (m_model) + m_model->appendMessages(toChatMessages(rows)); +} + +void ChatHistoryBridge::onRowUpdated(int index, const Session::MessageRow &row) +{ + if (m_model) + m_model->updateMessage(index, toChatMessage(row)); +} + +void ChatHistoryBridge::onRowsRemoved(int first, int count) +{ + if (m_model) + m_model->removeMessages(first, count); +} + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatHistoryBridge.hpp b/sources/ChatView/ChatHistoryBridge.hpp new file mode 100644 index 0000000..5f21b1f --- /dev/null +++ b/sources/ChatView/ChatHistoryBridge.hpp @@ -0,0 +1,37 @@ +// 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 +#include +#include + +#include "session/HistoryProjection.hpp" + +namespace QodeAssist::Session { +class Session; +} + +namespace QodeAssist::Chat { + +class ChatModel; + +class ChatHistoryBridge : public QObject +{ + Q_OBJECT + +public: + ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent = nullptr); + +private: + void onRowsReset(const QList &rows); + void onRowsAppended(const QList &rows); + void onRowUpdated(int index, const Session::MessageRow &row); + void onRowsRemoved(int first, int count); + + QPointer m_model; +}; + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatHistoryStore.cpp b/sources/ChatView/ChatHistoryStore.cpp index 6c5332c..12ffcc2 100644 --- a/sources/ChatView/ChatHistoryStore.cpp +++ b/sources/ChatView/ChatHistoryStore.cpp @@ -16,15 +16,15 @@ #include #include -#include "ChatModel.hpp" #include "Logger.hpp" #include "ProjectSettings.hpp" +#include "session/Session.hpp" namespace QodeAssist::Chat { -ChatHistoryStore::ChatHistoryStore(ChatModel *chatModel, QObject *parent) +ChatHistoryStore::ChatHistoryStore(Session::Session *session, QObject *parent) : QObject(parent) - , m_chatModel(chatModel) + , m_session(session) {} QString ChatHistoryStore::historyDir() const @@ -52,18 +52,15 @@ QString ChatHistoryStore::suggestedFileName() const { QString shortMessage; - if (m_chatModel->rowCount() > 0) { - QString firstMessage - = m_chatModel->data(m_chatModel->index(0), ChatModel::Content).toString(); - shortMessage = firstMessage.split('\n').first().simplified().left(30); + if (!m_session) + return generateChatFileName(shortMessage, historyDir()); - if (shortMessage.isEmpty()) { - QVariantList images - = m_chatModel->data(m_chatModel->index(0), ChatModel::Images).toList(); - if (!images.isEmpty()) { - shortMessage = "image_chat"; - } - } + const QList &rows = m_session->rows(); + if (!rows.isEmpty()) { + shortMessage = rows.first().content.split('\n').first().simplified().left(30); + + if (shortMessage.isEmpty() && !rows.first().images.isEmpty()) + shortMessage = "image_chat"; } return generateChatFileName(shortMessage, historyDir()); @@ -107,12 +104,22 @@ QString ChatHistoryStore::autosaveFilePath( SerializationResult ChatHistoryStore::save(const QString &filePath) const { - return ChatSerializer::saveToFile(m_chatModel, filePath); + if (!m_session) + return {false, QString("Chat session is no longer available")}; + + return ChatSerializer::saveToFile(m_session->history(), filePath); } SerializationResult ChatHistoryStore::load(const QString &filePath) const { - return ChatSerializer::loadFromFile(m_chatModel, filePath); + if (!m_session) + return {false, QString("Chat session is no longer available")}; + + Session::ConversationHistory history; + const SerializationResult result = ChatSerializer::loadFromFile(history, filePath); + if (result.success) + m_session->setHistory(history); + return result; } void ChatHistoryStore::showSaveDialog() diff --git a/sources/ChatView/ChatHistoryStore.hpp b/sources/ChatView/ChatHistoryStore.hpp index 0de31ed..bd16f82 100644 --- a/sources/ChatView/ChatHistoryStore.hpp +++ b/sources/ChatView/ChatHistoryStore.hpp @@ -5,20 +5,23 @@ #pragma once #include +#include #include #include "ChatSerializer.hpp" -namespace QodeAssist::Chat { +namespace QodeAssist::Session { +class Session; +} -class ChatModel; +namespace QodeAssist::Chat { class ChatHistoryStore : public QObject { Q_OBJECT public: - explicit ChatHistoryStore(ChatModel *chatModel, QObject *parent = nullptr); + explicit ChatHistoryStore(Session::Session *session, QObject *parent = nullptr); QString historyDir() const; QString suggestedFileName() const; @@ -42,7 +45,7 @@ signals: private: QString generateChatFileName(const QString &shortMessage, const QString &dir) const; - ChatModel *m_chatModel; + QPointer m_session; }; } // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatModel.cpp b/sources/ChatView/ChatModel.cpp index d5973fa..145e623 100644 --- a/sources/ChatView/ChatModel.cpp +++ b/sources/ChatView/ChatModel.cpp @@ -3,38 +3,41 @@ // Additional attribution terms under GPLv3 §7(b) apply — see LICENSE #include "ChatModel.hpp" -#include -#include + #include #include -#include -#include +#include #include -#include -#include "Logger.hpp" -#include "context/ChangesManager.h" +#include +#include + +#include "logger/Logger.hpp" namespace QodeAssist::Chat { +namespace { + +auto usageOf(const ChatModel::Message &message) +{ + return std::tie( + message.promptTokens, + message.completionTokens, + message.cachedPromptTokens, + message.reasoningTokens); +} + +bool carriesUsage(const ChatModel::Message &message) +{ + return message.promptTokens != 0 || message.completionTokens != 0 + || message.cachedPromptTokens != 0 || message.reasoningTokens != 0; +} + +} // namespace + ChatModel::ChatModel(QObject *parent) : QAbstractListModel(parent) -{ - connect(&Context::ChangesManager::instance(), - &Context::ChangesManager::fileEditApplied, - this, - &ChatModel::onFileEditApplied); - - connect(&Context::ChangesManager::instance(), - &Context::ChangesManager::fileEditRejected, - this, - &ChatModel::onFileEditRejected); - - connect(&Context::ChangesManager::instance(), - &Context::ChangesManager::fileEditArchived, - this, - &ChatModel::onFileEditArchived); -} +{} int ChatModel::rowCount(const QModelIndex &parent) const { @@ -134,93 +137,70 @@ QHash ChatModel::roleNames() const return roles; } -void ChatModel::addMessage( - const QString &content, - ChatRole role, - const QString &id, - const QList &attachments, - const QList &images, - bool isRedacted, - const QString &signature) -{ - if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id - && m_messages.last().role == role) { - Message &lastMessage = m_messages.last(); - lastMessage.content = content; - lastMessage.attachments = attachments; - lastMessage.images = images; - lastMessage.isRedacted = isRedacted; - lastMessage.signature = signature; - emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); - } else { - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message newMessage{role, content, id}; - newMessage.attachments = attachments; - newMessage.images = images; - newMessage.isRedacted = isRedacted; - newMessage.signature = signature; - m_messages.append(newMessage); - endInsertRows(); - - if (m_loadingFromHistory && role == ChatRole::FileEdit) { - const QString marker = "QODEASSIST_FILE_EDIT:"; - if (content.contains(marker)) { - int markerPos = content.indexOf(marker); - int jsonStart = markerPos + marker.length(); - - if (jsonStart < content.length()) { - QString jsonStr = content.mid(jsonStart); - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); - - if (doc.isObject()) { - QJsonObject editData = doc.object(); - QString editId = editData.value("edit_id").toString(); - QString filePath = editData.value("file").toString(); - QString oldContent = editData.value("old_content").toString(); - QString newContent = editData.value("new_content").toString(); - QString originalStatus = editData.value("status").toString(); - - if (!editId.isEmpty() && !filePath.isEmpty()) { - Context::ChangesManager::instance().addFileEdit( - editId, filePath, oldContent, newContent, false, true); - - editData["status"] = "archived"; - editData["status_message"] = "Loaded from chat history"; - - QString updatedContent = marker - + QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact)); - m_messages.last().content = updatedContent; - - emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); - - LOG_MESSAGE(QString("Registered historical file edit: %1 (original status: %2, now: archived)") - .arg(editId, originalStatus)); - } - } - } - } - } - } -} - -QVector ChatModel::getChatHistory() const -{ - return m_messages; -} - -void ChatModel::clear() +void ChatModel::resetMessages(const QVector &messages) { beginResetModel(); - m_messages.clear(); + m_messages = messages; endResetModel(); emit modelReseted(); emit sessionUsageChanged(); } +void ChatModel::appendMessages(const QVector &messages) +{ + if (messages.isEmpty()) + return; + + beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + messages.size() - 1); + m_messages.append(messages); + endInsertRows(); + + if (std::any_of(messages.cbegin(), messages.cend(), carriesUsage)) + emit sessionUsageChanged(); +} + +void ChatModel::updateMessage(int index, const Message &message) +{ + if (index < 0 || index >= m_messages.size()) { + LOG_MESSAGE(QString("Session/model desync: update of row %1 with %2 rows present") + .arg(index) + .arg(m_messages.size())); + return; + } + + const bool usageChanged = usageOf(m_messages[index]) != usageOf(message); + m_messages[index] = message; + emit dataChanged(this->index(index), this->index(index)); + + if (usageChanged) + emit sessionUsageChanged(); +} + +void ChatModel::removeMessages(int first, int count) +{ + if (first < 0 || count <= 0 || first + count > m_messages.size()) { + LOG_MESSAGE(QString("Session/model desync: removal of %1 rows at %2 with %3 rows present") + .arg(count) + .arg(first) + .arg(m_messages.size())); + return; + } + + const bool usageChanged + = std::any_of(m_messages.cbegin() + first, m_messages.cbegin() + first + count, carriesUsage); + + beginRemoveRows(QModelIndex(), first, first + count - 1); + m_messages.remove(first, count); + endRemoveRows(); + + if (usageChanged) + emit sessionUsageChanged(); +} + QList ChatModel::processMessageContent(const QString &content) const { QList parts; - QRegularExpression codeBlockRegex("```(\\w*)\\n?([\\s\\S]*?)```"); + static const QRegularExpression codeBlockRegex("```(\\w*)\\n?([\\s\\S]*?)```"); int lastIndex = 0; auto blockMatches = codeBlockRegex.globalMatch(content); @@ -249,7 +229,7 @@ QList ChatModel::processMessageContent(const QString &content) cons if (lastIndex < content.length()) { QString remainingText = content.mid(lastIndex).trimmed(); - QRegularExpression unclosedBlockRegex("```(\\w*)\\n?([\\s\\S]*)$"); + static const QRegularExpression unclosedBlockRegex("```(\\w*)\\n?([\\s\\S]*)$"); auto unclosedMatch = unclosedBlockRegex.match(remainingText); if (unclosedMatch.hasMatch()) { @@ -277,65 +257,6 @@ QList ChatModel::processMessageContent(const QString &content) cons return parts; } -QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) const -{ - QJsonArray messages; - messages.append(QJsonObject{{"role", "system"}, {"content", systemPrompt}}); - - for (const auto &message : m_messages) { - QString role; - switch (message.role) { - case ChatRole::User: - role = "user"; - break; - case ChatRole::Assistant: - role = "assistant"; - break; - case ChatRole::Tool: - case ChatRole::FileEdit: - continue; - default: - continue; - } - - QString content - = message.attachments.isEmpty() - ? message.content - : message.content + "\n\nAttached files list:" - + std::accumulate( - message.attachments.begin(), - message.attachments.end(), - QString(), - [](QString acc, const Context::ContentFile &attachment) { - return acc - + QString("\nname: %1\nfile content:\n%2") - .arg(attachment.filename, attachment.content); - }); - - messages.append(QJsonObject{{"role", role}, {"content", content}}); - } - - return messages; -} - -QString ChatModel::lastMessageId() const -{ - return !m_messages.isEmpty() ? m_messages.last().id : ""; -} - -void ChatModel::resetModelTo(int index) -{ - if (index < 0 || index >= m_messages.size()) - return; - - if (index < m_messages.size()) { - beginRemoveRows(QModelIndex(), index, m_messages.size() - 1); - m_messages.remove(index, m_messages.size() - index); - endRemoveRows(); - emit sessionUsageChanged(); - } -} - QVariantList ChatModel::userMessagePreviews(int maxLength) const { QVariantList result; @@ -358,248 +279,6 @@ QVariantList ChatModel::userMessagePreviews(int maxLength) const return result; } -void ChatModel::addToolExecutionStatus( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments) -{ - QString content = toolName; - - LOG_MESSAGE(QString("Adding tool execution status: requestId=%1, toolId=%2, toolName=%3") - .arg(requestId, toolId, toolName)); - - if (!m_messages.isEmpty() && !toolId.isEmpty() && m_messages.last().id == toolId - && m_messages.last().role == ChatRole::Tool) { - Message &lastMessage = m_messages.last(); - lastMessage.content = content; - lastMessage.toolName = toolName; - lastMessage.toolArguments = toolArguments; - LOG_MESSAGE(QString("Updated existing tool message at index %1").arg(m_messages.size() - 1)); - emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); - } else { - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message newMessage{ChatRole::Tool, content, toolId}; - newMessage.toolName = toolName; - newMessage.toolArguments = toolArguments; - m_messages.append(newMessage); - endInsertRows(); - LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2") - .arg(m_messages.size() - 1) - .arg(toolId)); - } -} - -void ChatModel::dropTrailingAssistantMessage(const QString &requestId) -{ - if (m_messages.isEmpty()) - return; - - const Message &last = m_messages.last(); - if (last.role != ChatRole::Assistant || last.id != requestId) - return; - - const int idx = m_messages.size() - 1; - beginRemoveRows(QModelIndex(), idx, idx); - m_messages.removeLast(); - endRemoveRows(); - LOG_MESSAGE(QString("Dropped leaked pre-tool assistant message at index %1").arg(idx)); -} - -void ChatModel::setToolMessageData( - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments, - const QString &toolResult) -{ - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::Tool && m_messages[i].id == toolId) { - m_messages[i].toolName = toolName; - m_messages[i].toolArguments = toolArguments; - m_messages[i].toolResult = toolResult; - return; - } - } -} - -void ChatModel::updateToolResult( - const QString &requestId, const QString &toolId, const QString &toolName, const QString &result) -{ - if (m_messages.isEmpty() || toolId.isEmpty()) { - LOG_MESSAGE(QString("Cannot update tool result: messages empty=%1, toolId empty=%2") - .arg(m_messages.isEmpty()) - .arg(toolId.isEmpty())); - return; - } - - LOG_MESSAGE( - QString("Updating tool result: requestId=%1, toolId=%2, toolName=%3, result length=%4") - .arg(requestId, toolId, toolName) - .arg(result.length())); - - bool toolMessageFound = false; - for (int i = m_messages.size() - 1; i >= 0; --i) { - if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) { - m_messages[i].content = toolName + "\n" + result; - m_messages[i].toolName = toolName; - m_messages[i].toolResult = result; - emit dataChanged(index(i), index(i)); - toolMessageFound = true; - LOG_MESSAGE(QString("Updated tool result at index %1").arg(i)); - break; - } - } - - if (!toolMessageFound) { - LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!") - .arg(requestId, toolId)); - } - - const QString marker = "QODEASSIST_FILE_EDIT:"; - if (result.contains(marker)) { - LOG_MESSAGE(QString("File edit marker detected in tool result")); - - int markerPos = result.indexOf(marker); - int jsonStart = markerPos + marker.length(); - - if (jsonStart < result.length()) { - QString jsonStr = result.mid(jsonStart); - - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &parseError); - - if (parseError.error != QJsonParseError::NoError) { - LOG_MESSAGE(QString("ERROR: Failed to parse file edit JSON at offset %1: %2") - .arg(parseError.offset) - .arg(parseError.errorString())); - } else if (!doc.isObject()) { - LOG_MESSAGE( - QString("ERROR: Parsed JSON is not an object, is array=%1").arg(doc.isArray())); - } else { - QJsonObject editData = doc.object(); - - QString editId = editData.value("edit_id").toString(); - - if (editId.isEmpty()) { - editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch()); - } - - LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId)); - - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message fileEditMsg; - fileEditMsg.role = ChatRole::FileEdit; - fileEditMsg.content = result; - fileEditMsg.id = editId; - m_messages.append(fileEditMsg); - endInsertRows(); - - LOG_MESSAGE(QString("Added FileEdit message with editId=%1").arg(editId)); - } - } - } -} - -void ChatModel::addThinkingBlock( - const QString &requestId, const QString &thinking, const QString &signature) -{ - LOG_MESSAGE(QString("Adding thinking block: requestId=%1, thinking length=%2, signature length=%3") - .arg(requestId) - .arg(thinking.length()) - .arg(signature.length())); - - QString displayContent = thinking; - if (!signature.isEmpty()) { - displayContent += "\n[Signature: " + signature.left(40) + "...]"; - } - - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::Thinking && m_messages[i].id == requestId) { - m_messages[i].content = displayContent; - m_messages[i].signature = signature; - emit dataChanged(index(i), index(i)); - LOG_MESSAGE(QString("Updated existing thinking message at index %1").arg(i)); - return; - } - } - - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message thinkingMessage; - thinkingMessage.role = ChatRole::Thinking; - thinkingMessage.content = displayContent; - thinkingMessage.id = requestId; - thinkingMessage.isRedacted = false; - thinkingMessage.signature = signature; - m_messages.append(thinkingMessage); - endInsertRows(); - LOG_MESSAGE(QString("Added thinking message at index %1 with signature length=%2") - .arg(m_messages.size() - 1).arg(signature.length())); -} - -void ChatModel::addRedactedThinkingBlock(const QString &requestId, const QString &signature) -{ - LOG_MESSAGE( - QString("Adding redacted thinking block: requestId=%1, signature length=%2") - .arg(requestId) - .arg(signature.length())); - - QString displayContent = "[Thinking content redacted by safety systems]"; - if (!signature.isEmpty()) { - displayContent += "\n[Signature: " + signature.left(40) + "...]"; - } - - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message thinkingMessage; - thinkingMessage.role = ChatRole::Thinking; - thinkingMessage.content = displayContent; - thinkingMessage.id = requestId; - thinkingMessage.isRedacted = true; - thinkingMessage.signature = signature; - m_messages.append(thinkingMessage); - endInsertRows(); - LOG_MESSAGE(QString("Added redacted thinking message at index %1 with signature length=%2") - .arg(m_messages.size() - 1).arg(signature.length())); -} - -void ChatModel::updateMessageContent(const QString &messageId, const QString &newContent) -{ - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].id == messageId) { - m_messages[i].content = newContent; - emit dataChanged(index(i), index(i)); - LOG_MESSAGE(QString("Updated message content for id: %1").arg(messageId)); - break; - } - } -} - -void ChatModel::setMessageUsage( - const QString &messageId, - int promptTokens, - int completionTokens, - int cachedPromptTokens, - int reasoningTokens) -{ - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].id != messageId) - continue; - m_messages[i].promptTokens = promptTokens; - m_messages[i].completionTokens = completionTokens; - m_messages[i].cachedPromptTokens = cachedPromptTokens; - m_messages[i].reasoningTokens = reasoningTokens; - emit dataChanged( - index(i), - index(i), - {Roles::PromptTokens, - Roles::CompletionTokens, - Roles::CachedPromptTokens, - Roles::ReasoningTokens, - Roles::TotalTokens}); - emit sessionUsageChanged(); - return; - } -} - int ChatModel::sessionPromptTokens() const { int total = 0; @@ -629,71 +308,6 @@ int ChatModel::sessionTotalTokens() const return sessionPromptTokens() + sessionCompletionTokens(); } -void ChatModel::setLoadingFromHistory(bool loading) -{ - m_loadingFromHistory = loading; - LOG_MESSAGE(QString("ChatModel loading from history: %1").arg(loading ? "true" : "false")); -} - -bool ChatModel::isLoadingFromHistory() const -{ - return m_loadingFromHistory; -} - -void ChatModel::onFileEditApplied(const QString &editId) -{ - updateFileEditStatus(editId, "applied", "Successfully applied"); -} - -void ChatModel::onFileEditRejected(const QString &editId) -{ - updateFileEditStatus(editId, "rejected", "Rejected by user"); -} - -void ChatModel::onFileEditArchived(const QString &editId) -{ - updateFileEditStatus(editId, "archived", "Archived (from previous conversation turn)"); -} - -void ChatModel::updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage) -{ - const QString marker = "QODEASSIST_FILE_EDIT:"; - - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::FileEdit && m_messages[i].id == editId) { - const QString &content = m_messages[i].content; - - if (content.contains(marker)) { - int markerPos = content.indexOf(marker); - int jsonStart = markerPos + marker.length(); - - if (jsonStart < content.length()) { - QString jsonStr = content.mid(jsonStart); - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); - - if (doc.isObject()) { - QJsonObject editData = doc.object(); - - editData["status"] = status; - editData["status_message"] = statusMessage; - - QString updatedContent = marker - + QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact)); - - m_messages[i].content = updatedContent; - - emit dataChanged(index(i), index(i)); - - LOG_MESSAGE(QString("Updated FileEdit message status: editId=%1, status=%2") - .arg(editId, status)); - break; - } - } - } - } - } -} - void ChatModel::setChatFilePath(const QString &filePath) { m_chatFilePath = filePath; diff --git a/sources/ChatView/ChatModel.hpp b/sources/ChatView/ChatModel.hpp index 79c7db7..2b049c6 100644 --- a/sources/ChatView/ChatModel.hpp +++ b/sources/ChatView/ChatModel.hpp @@ -4,11 +4,9 @@ #pragma once -#include "llmcore/ContextData.hpp" #include "MessagePart.hpp" #include -#include #include #include @@ -77,62 +75,19 @@ public: QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - Q_INVOKABLE void addMessage( - const QString &content, - ChatRole role, - const QString &id, - const QList &attachments = {}, - const QList &images = {}, - bool isRedacted = false, - const QString &signature = QString()); - Q_INVOKABLE void clear(); + void resetMessages(const QVector &messages); + void appendMessages(const QVector &messages); + void updateMessage(int index, const Message &message); + void removeMessages(int first, int count); + Q_INVOKABLE QList processMessageContent(const QString &content) const; - - QVector getChatHistory() const; - QJsonArray prepareMessagesForRequest(const QString &systemPrompt) const; - - QString currentModel() const; - QString lastMessageId() const; - - Q_INVOKABLE void resetModelTo(int index); Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const; - void addToolExecutionStatus( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments); - void dropTrailingAssistantMessage(const QString &requestId); - void setToolMessageData( - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments, - const QString &toolResult); - void updateToolResult( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QString &result); - void addThinkingBlock( - const QString &requestId, const QString &thinking, const QString &signature); - void addRedactedThinkingBlock(const QString &requestId, const QString &signature); - void updateMessageContent(const QString &messageId, const QString &newContent); - - void setMessageUsage( - const QString &messageId, - int promptTokens, - int completionTokens, - int cachedPromptTokens, - int reasoningTokens); - int sessionPromptTokens() const; int sessionCompletionTokens() const; int sessionCachedPromptTokens() const; int sessionTotalTokens() const; - - void setLoadingFromHistory(bool loading); - bool isLoadingFromHistory() const; - + void setChatFilePath(const QString &filePath); QString chatFilePath() const; @@ -140,16 +95,8 @@ signals: void modelReseted(); void sessionUsageChanged(); -private slots: - void onFileEditApplied(const QString &editId); - void onFileEditRejected(const QString &editId); - void onFileEditArchived(const QString &editId); - private: - void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage); - QVector m_messages; - bool m_loadingFromHistory = false; QString m_chatFilePath; }; diff --git a/sources/ChatView/ChatRootView.cpp b/sources/ChatView/ChatRootView.cpp index 1223655..1711d36 100644 --- a/sources/ChatView/ChatRootView.cpp +++ b/sources/ChatView/ChatRootView.cpp @@ -75,16 +75,16 @@ ChatRootView::ChatRootView(QQuickItem *parent) : QQuickItem(parent) , m_chatModel(new ChatModel(this)) , m_promptProvider(Templates::PromptTemplateManager::instance()) - , m_clientInterface(new ClientInterface(m_chatModel, &m_promptProvider, this)) + , m_controller(new ChatController(m_chatModel, &m_promptProvider, this)) , m_fileManager(new ChatFileManager(this)) , m_isRequestInProgress(false) , m_chatCompressor(new ChatCompressor(this)) , m_agentRoleController(new AgentRoleController(this)) , m_configurationController(new ChatConfigurationController(this)) - , m_fileEditController(new FileEditController(m_chatModel, this)) - , m_tokenCounter( - new InputTokenCounter(m_chatModel, m_clientInterface->contextManager(), this)) - , m_historyStore(new ChatHistoryStore(m_chatModel, this)) + , m_fileEditController(new FileEditController(this)) + , m_tokenCounter(new InputTokenCounter( + m_controller->session(), m_controller->contextManager(), this)) + , m_historyStore(new ChatHistoryStore(m_controller->session(), this)) { m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles(); connect( @@ -126,18 +126,18 @@ ChatRootView::ChatRootView(QQuickItem *parent) &ChatRootView::currentConfigurationChanged); connect( - m_clientInterface, - &ClientInterface::messageReceivedCompletely, + m_controller, + &ChatController::messageReceivedCompletely, this, &ChatRootView::autosave); - connect(m_clientInterface, &ClientInterface::messageReceivedCompletely, this, [this]() { + connect(m_controller, &ChatController::messageReceivedCompletely, this, [this]() { this->setRequestProgressStatus(false); }); connect( - m_clientInterface, - &ClientInterface::messageReceivedCompletely, + m_controller, + &ChatController::messageReceivedCompletely, this, &ChatRootView::updateInputTokensCount); @@ -156,7 +156,6 @@ ChatRootView::ChatRootView(QQuickItem *parent) connect(m_chatModel, &QAbstractItemModel::modelReset, this, maybeEmitTitle); connect(m_chatModel, &QAbstractItemModel::rowsInserted, this, maybeEmitTitle); connect(m_chatModel, &QAbstractItemModel::rowsRemoved, this, maybeEmitTitle); - connect(m_chatModel, &QAbstractItemModel::dataChanged, this, maybeEmitTitle); connect(this, &ChatRootView::attachmentFilesChanged, this, [this]() { m_tokenCounter->setAttachments(m_attachmentFiles); }); @@ -227,21 +226,21 @@ ChatRootView::ChatRootView(QQuickItem *parent) &Utils::BaseAspect::changed, this, &ChatRootView::textFormatChanged); - connect(m_clientInterface, &ClientInterface::errorOccurred, this, [this](const QString &error) { + connect(m_controller, &ChatController::errorOccurred, this, [this](const QString &error) { this->setRequestProgressStatus(false); m_lastErrorMessage = error; emit lastErrorMessageChanged(); }); connect( - m_clientInterface, - &ClientInterface::requestStarted, + m_controller, + &ChatController::requestStarted, this, [this](const QString &requestId) { m_fileEditController->setCurrentRequestId(requestId); }); connect( - m_clientInterface, - &ClientInterface::messageUsageReceived, + m_controller, + &ChatController::messageUsageReceived, this, [this](int promptTokens, int /*completionTokens*/, int /*cached*/, int /*reasoning*/) { m_tokenCounter->recordServerUsage(promptTokens); @@ -480,13 +479,13 @@ void ChatRootView::dispatchSend( } m_tokenCounter->recordSent(); + setRequestProgressStatus(true); - m_clientInterface->setSkillsManager(skillsManager()); - m_clientInterface->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg); + m_controller->setSkillsManager(skillsManager()); + m_controller->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg); m_fileManager->clearIntermediateStorage(); clearAttachmentFiles(); - setRequestProgressStatus(true); } void ChatRootView::copyToClipboard(const QString &text) @@ -496,7 +495,7 @@ void ChatRootView::copyToClipboard(const QString &text) void ChatRootView::cancelRequest() { - m_clientInterface->cancelRequest(); + m_controller->cancelRequest(); setRequestProgressStatus(false); } @@ -523,10 +522,16 @@ void ChatRootView::clearLinkedFiles() void ChatRootView::clearMessages() { - m_clientInterface->clearMessages(); + m_controller->clearMessages(); clearLinkedFiles(); } +void ChatRootView::resetChatToMessage(int index) +{ + m_controller->resetToRow(index); + setRequestProgressStatus(false); +} + QString ChatRootView::currentTemplate() const { auto &settings = Settings::generalSettings(); @@ -568,6 +573,11 @@ void ChatRootView::loadHistory(const QString &filePath) LOG_MESSAGE(QString("Failed to load chat history: %1").arg(result.errorMessage)); } else { setRecentFilePath(filePath); + setRequestProgressStatus(false); + if (!result.warningMessage.isEmpty()) { + m_lastErrorMessage = result.warningMessage; + emit lastErrorMessageChanged(); + } } if (!m_pendingSend.active) @@ -890,13 +900,10 @@ QString ChatRootView::chatTitle() const QString ChatRootView::computeChatTitle() const { - if (!m_chatModel) - return {}; - const auto history = m_chatModel->getChatHistory(); - for (const auto &msg : history) { - if (msg.role != ChatModel::User) + for (const Session::MessageRow &row : m_controller->session()->rows()) { + if (row.kind != Session::RowKind::User) continue; - const QString content = msg.content.trimmed(); + const QString content = row.content.trimmed(); if (content.isEmpty()) continue; const QString firstLine = content.section(QChar('\n'), 0, 0).trimmed(); @@ -1057,7 +1064,7 @@ void ChatRootView::setRecentFilePath(const QString &filePath) } m_recentFilePath = filePath; - m_clientInterface->setChatFilePath(filePath); + m_controller->setChatFilePath(filePath); m_fileManager->setChatFilePath(filePath); emit chatFileNameChanged(); } @@ -1066,7 +1073,7 @@ bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath) { auto project = ProjectExplorer::ProjectManager::projectForFile(filePath); if (project - && m_clientInterface->contextManager() + && m_controller->contextManager() ->ignoreManager() ->shouldIgnore(filePath.toFSPathString(), project)) { LOG_MESSAGE(QString("Ignoring file for attachment due to .qodeassistignore: %1") @@ -1349,7 +1356,8 @@ void ChatRootView::compressCurrentChat() autosave(); - m_chatCompressor->startCompression(m_recentFilePath, m_chatModel); + m_chatCompressor->startCompression( + m_recentFilePath, m_controller->session()->history()); } void ChatRootView::cancelCompression() diff --git a/sources/ChatView/ChatRootView.hpp b/sources/ChatView/ChatRootView.hpp index c56f617..c186789 100644 --- a/sources/ChatView/ChatRootView.hpp +++ b/sources/ChatView/ChatRootView.hpp @@ -8,9 +8,9 @@ #include #include +#include "ChatController.hpp" #include "ChatFileManager.hpp" #include "ChatModel.hpp" -#include "ClientInterface.hpp" #include "templates/PromptProviderChat.hpp" #include @@ -203,6 +203,7 @@ public slots: void clearAttachmentFiles(); void clearLinkedFiles(); void clearMessages(); + void resetChatToMessage(int index); signals: void chatModelChanged(); @@ -272,7 +273,7 @@ private: ChatModel *m_chatModel; Templates::PromptProviderChat m_promptProvider; - ClientInterface *m_clientInterface; + ChatController *m_controller; ChatFileManager *m_fileManager; QString m_currentTemplate; QString m_recentFilePath; diff --git a/sources/ChatView/ChatSerializer.cpp b/sources/ChatView/ChatSerializer.cpp index 120a4c5..86281da 100644 --- a/sources/ChatView/ChatSerializer.cpp +++ b/sources/ChatView/ChatSerializer.cpp @@ -5,40 +5,45 @@ #include "ChatSerializer.hpp" #include "Logger.hpp" -#include #include #include #include -#include #include +#include +#include #include +#include "session/HistorySerializer.hpp" + namespace QodeAssist::Chat { -const QString ChatSerializer::VERSION = "0.2"; - -SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath) +SerializationResult ChatSerializer::saveToFile( + const Session::ConversationHistory &history, const QString &filePath) { if (!ensureDirectoryExists(filePath)) { return {false, "Failed to create directory structure"}; } - QFile file(filePath); + const QJsonObject root = Session::HistorySerializer::toJson(history); + + QSaveFile file(filePath); if (!file.open(QIODevice::WriteOnly)) { return {false, QString("Failed to open file for writing: %1").arg(filePath)}; } - QJsonObject root = serializeChat(model, filePath); - QJsonDocument doc(root); - - if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) { + if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) { return {false, QString("Failed to write to file: %1").arg(file.errorString())}; } + if (!file.commit()) { + return {false, QString("Failed to save file: %1").arg(file.errorString())}; + } + return {true, QString()}; } -SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString &filePath) +SerializationResult ChatSerializer::loadFromFile( + Session::ConversationHistory &history, const QString &filePath) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { @@ -51,180 +56,37 @@ SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString return {false, QString("JSON parse error: %1").arg(error.errorString())}; } - QJsonObject root = doc.object(); - QString version = root["version"].toString(); + const QJsonObject root = doc.object(); + const QString version = root["version"].toString(); - if (!validateVersion(version)) { + if (!Session::HistorySerializer::isSupportedVersion(version)) { return {false, QString("Unsupported version: %1").arg(version)}; } - if (!deserializeChat(model, root, filePath)) { - return {false, "Failed to deserialize chat data"}; + int droppedBlocks = 0; + const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks); + if (!loaded) { + return {false, QString("Failed to read chat history from: %1").arg(filePath)}; } - return {true, QString()}; -} - -QJsonObject ChatSerializer::serializeMessage( - const ChatModel::Message &message, const QString &chatFilePath) -{ - QJsonObject messageObj; - messageObj["role"] = static_cast(message.role); - messageObj["content"] = message.content; - messageObj["id"] = message.id; - - if (message.isRedacted) { - messageObj["isRedacted"] = true; + if (version != Session::HistorySerializer::currentVersion()) { + LOG_MESSAGE(QString("Converted chat from format %1 to %2") + .arg(version, Session::HistorySerializer::currentVersion())); } - if (!message.signature.isEmpty()) { - messageObj["signature"] = message.signature; + history = *loaded; + + if (droppedBlocks > 0) { + const QString warning + = QString( + "%1 message part(s) in this chat could not be read and will be lost if " + "the chat is saved again") + .arg(droppedBlocks); + LOG_MESSAGE(QString("%1: %2").arg(filePath, warning)); + return {true, QString(), warning}; } - if (message.role == ChatModel::ChatRole::Tool) { - if (!message.toolName.isEmpty()) - messageObj["toolName"] = message.toolName; - if (!message.toolArguments.isEmpty()) - messageObj["toolArguments"] = message.toolArguments; - if (!message.toolResult.isEmpty()) - messageObj["toolResult"] = message.toolResult; - } - - if (!message.attachments.isEmpty()) { - QJsonArray attachmentsArray; - for (const auto &attachment : message.attachments) { - QJsonObject attachmentObj; - attachmentObj["fileName"] = attachment.filename; - attachmentObj["storedPath"] = attachment.content; - attachmentsArray.append(attachmentObj); - } - messageObj["attachments"] = attachmentsArray; - } - - if (!message.images.isEmpty()) { - QJsonArray imagesArray; - for (const auto &image : message.images) { - QJsonObject imageObj; - imageObj["fileName"] = image.fileName; - imageObj["storedPath"] = image.storedPath; - imageObj["mediaType"] = image.mediaType; - imagesArray.append(imageObj); - } - messageObj["images"] = imagesArray; - } - - if (message.promptTokens > 0 || message.completionTokens > 0) { - QJsonObject usageObj; - usageObj["promptTokens"] = message.promptTokens; - usageObj["completionTokens"] = message.completionTokens; - if (message.cachedPromptTokens > 0) - usageObj["cachedPromptTokens"] = message.cachedPromptTokens; - if (message.reasoningTokens > 0) - usageObj["reasoningTokens"] = message.reasoningTokens; - messageObj["usage"] = usageObj; - } - - return messageObj; -} - -ChatModel::Message ChatSerializer::deserializeMessage( - const QJsonObject &json, const QString &chatFilePath) -{ - ChatModel::Message message; - message.role = static_cast(json["role"].toInt()); - message.content = json["content"].toString(); - message.id = json["id"].toString(); - message.isRedacted = json["isRedacted"].toBool(false); - message.signature = json["signature"].toString(); - message.toolName = json["toolName"].toString(); - message.toolArguments = json["toolArguments"].toObject(); - message.toolResult = json["toolResult"].toString(); - - if (json.contains("attachments")) { - QJsonArray attachmentsArray = json["attachments"].toArray(); - for (const auto &attachmentValue : attachmentsArray) { - QJsonObject attachmentObj = attachmentValue.toObject(); - Context::ContentFile attachment; - attachment.filename = attachmentObj["fileName"].toString(); - attachment.content = attachmentObj["storedPath"].toString(); - message.attachments.append(attachment); - } - } - - if (json.contains("images")) { - QJsonArray imagesArray = json["images"].toArray(); - for (const auto &imageValue : imagesArray) { - QJsonObject imageObj = imageValue.toObject(); - ChatModel::ImageAttachment image; - image.fileName = imageObj["fileName"].toString(); - image.storedPath = imageObj["storedPath"].toString(); - image.mediaType = imageObj["mediaType"].toString(); - message.images.append(image); - } - } - - if (json.contains("usage")) { - const QJsonObject usageObj = json["usage"].toObject(); - message.promptTokens = usageObj["promptTokens"].toInt(); - message.completionTokens = usageObj["completionTokens"].toInt(); - message.cachedPromptTokens = usageObj["cachedPromptTokens"].toInt(); - message.reasoningTokens = usageObj["reasoningTokens"].toInt(); - } - - return message; -} - -QJsonObject ChatSerializer::serializeChat(const ChatModel *model, const QString &chatFilePath) -{ - QJsonArray messagesArray; - for (const auto &message : model->getChatHistory()) { - messagesArray.append(serializeMessage(message, chatFilePath)); - } - - QJsonObject root; - root["version"] = VERSION; - root["messages"] = messagesArray; - - return root; -} - -bool ChatSerializer::deserializeChat( - ChatModel *model, const QJsonObject &json, const QString &chatFilePath) -{ - QJsonArray messagesArray = json["messages"].toArray(); - QVector messages; - messages.reserve(messagesArray.size()); - - for (const auto &messageValue : messagesArray) { - messages.append(deserializeMessage(messageValue.toObject(), chatFilePath)); - } - - model->clear(); - - model->setLoadingFromHistory(true); - - for (const auto &message : messages) { - model->addMessage( - message.content, - message.role, - message.id, - message.attachments, - message.images, - message.isRedacted, - message.signature); - if (message.role == ChatModel::ChatRole::Tool) { - model->setToolMessageData( - message.id, message.toolName, message.toolArguments, message.toolResult); - } - LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3") - .arg(message.images.size()) - .arg(message.isRedacted) - .arg(message.signature.length())); - } - - model->setLoadingFromHistory(false); - - return true; + return {true, QString(), QString()}; } bool ChatSerializer::ensureDirectoryExists(const QString &filePath) @@ -234,22 +96,6 @@ bool ChatSerializer::ensureDirectoryExists(const QString &filePath) return dir.exists() || dir.mkpath("."); } -bool ChatSerializer::validateVersion(const QString &version) -{ - if (version == VERSION) { - return true; - } - - if (version == "0.1") { - LOG_MESSAGE( - "Loading chat from old format 0.1 - images folder structure has changed from _images " - "to _content"); - return true; - } - - return false; -} - QString ChatSerializer::getChatContentFolder(const QString &chatFilePath) { QFileInfo fileInfo(chatFilePath); diff --git a/sources/ChatView/ChatSerializer.hpp b/sources/ChatView/ChatSerializer.hpp index 82c1075..6d95373 100644 --- a/sources/ChatView/ChatSerializer.hpp +++ b/sources/ChatView/ChatSerializer.hpp @@ -4,11 +4,9 @@ #pragma once -#include -#include #include -#include "ChatModel.hpp" +#include "session/ConversationHistory.hpp" namespace QodeAssist::Chat { @@ -16,34 +14,27 @@ struct SerializationResult { bool success{false}; QString errorMessage; + QString warningMessage; }; class ChatSerializer { public: - static SerializationResult saveToFile(const ChatModel *model, const QString &filePath); - static SerializationResult loadFromFile(ChatModel *model, const QString &filePath); - - // Public for testing purposes - static QJsonObject serializeMessage(const ChatModel::Message &message, const QString &chatFilePath); - static ChatModel::Message deserializeMessage(const QJsonObject &json, const QString &chatFilePath); - static QJsonObject serializeChat(const ChatModel *model, const QString &chatFilePath); - static bool deserializeChat(ChatModel *model, const QJsonObject &json, const QString &chatFilePath); + static SerializationResult saveToFile( + const Session::ConversationHistory &history, const QString &filePath); + static SerializationResult loadFromFile( + Session::ConversationHistory &history, const QString &filePath); // Content management (images and text files) static QString getChatContentFolder(const QString &chatFilePath); - static bool saveContentToStorage(const QString &chatFilePath, + static bool saveContentToStorage(const QString &chatFilePath, const QString &fileName, const QString &base64Data, QString &storedPath); static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath); private: - static const QString VERSION; - static constexpr int CURRENT_VERSION = 1; - static bool ensureDirectoryExists(const QString &filePath); - static bool validateVersion(const QString &version); }; } // namespace QodeAssist::Chat diff --git a/sources/ChatView/ClientInterface.cpp b/sources/ChatView/ClientInterface.cpp deleted file mode 100644 index d7275ec..0000000 --- a/sources/ChatView/ClientInterface.cpp +++ /dev/null @@ -1,745 +0,0 @@ -// 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 "ClientInterface.hpp" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include - -#include - -#include "tools/ReadOriginalHistoryTool.hpp" -#include "tools/TodoTool.hpp" - -#include "ChatAssistantSettings.hpp" -#include "ChatSerializer.hpp" -#include "GeneralSettings.hpp" -#include "Logger.hpp" -#include "ProjectSettings.hpp" -#include "providers/ProvidersManager.hpp" -#include "SkillsSettings.hpp" -#include "ToolsSettings.hpp" -#include "context/RulesLoader.hpp" -#include -#include "skills/SkillsManager.hpp" - -namespace QodeAssist::Chat { - -ClientInterface::ClientInterface( - ChatModel *chatModel, Templates::IPromptProvider *promptProvider, QObject *parent) - : QObject(parent) - , m_promptProvider(promptProvider) - , m_chatModel(chatModel) - , m_contextManager(new Context::ContextManager(this)) -{} - -void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager) -{ - m_skillsManager = skillsManager; -} - -ClientInterface::~ClientInterface() -{ - cancelRequest(); -} - -void ClientInterface::sendMessage( - const QString &message, - const QList &attachments, - const QList &linkedFiles, - bool useTools, - bool useThinking) -{ - if (message.trimmed().isEmpty() && attachments.isEmpty()) { - LOG_MESSAGE("Ignoring empty chat message"); - return; - } - - cancelRequest(); - m_accumulatedResponses.clear(); - - Context::ChangesManager::instance().archiveAllNonArchivedEdits(); - - QList imageFiles; - QList textFiles; - - for (const QString &filePath : attachments) { - if (isImageFile(filePath)) { - imageFiles.append(filePath); - } else { - textFiles.append(filePath); - } - } - - QList storedAttachments; - if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) { - auto attachFiles = m_contextManager->getContentFiles(textFiles); - for (const auto &file : attachFiles) { - QString storedPath; - if (ChatSerializer::saveContentToStorage( - m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) { - Context::ContentFile storedFile; - storedFile.filename = file.filename; - storedFile.content = storedPath; - storedAttachments.append(storedFile); - LOG_MESSAGE(QString("Stored text file %1 as %2").arg(file.filename, storedPath)); - } - } - } else if (!textFiles.isEmpty()) { - LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 text file(s)") - .arg(textFiles.size())); - } - - QList imageAttachments; - if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) { - for (const QString &imagePath : imageFiles) { - QString base64Data = encodeImageToBase64(imagePath); - if (base64Data.isEmpty()) { - continue; - } - - QString storedPath; - QFileInfo fileInfo(imagePath); - if (ChatSerializer::saveContentToStorage( - m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) { - ChatModel::ImageAttachment imageAttachment; - imageAttachment.fileName = fileInfo.fileName(); - imageAttachment.storedPath = storedPath; - imageAttachment.mediaType = getMediaTypeForImage(imagePath); - imageAttachments.append(imageAttachment); - - LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath)); - } - } - } else if (!imageFiles.isEmpty()) { - LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 image(s)") - .arg(imageFiles.size())); - } - - m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", storedAttachments, imageAttachments); - - auto &chatAssistantSettings = Settings::chatAssistantSettings(); - - auto providerName = Settings::generalSettings().caProvider(); - auto provider = Providers::ProvidersManager::instance().getProviderByName(providerName); - - if (!provider) { - LOG_MESSAGE(QString("No provider found with name: %1").arg(providerName)); - return; - } - - auto templateName = Settings::generalSettings().caTemplate(); - auto promptTemplate = m_promptProvider->getTemplateByName(templateName); - - if (!promptTemplate) { - LOG_MESSAGE(QString("No template found with name: %1").arg(templateName)); - return; - } - - LLMCore::ContextData context; - - const bool isToolsEnabled = useTools; - - if (chatAssistantSettings.useSystemPrompt()) { - QString systemPrompt = chatAssistantSettings.systemPrompt(); - - const QString lastRoleId = chatAssistantSettings.lastUsedRoleId(); - if (!lastRoleId.isEmpty()) { - const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); - if (!role.id.isEmpty()) - systemPrompt = systemPrompt + "\n\n" + role.systemPrompt; - } - - auto project = Context::RulesLoader::getActiveProject(); - - if (project) { - systemPrompt += QString("\n# Active project: %1").arg(project->displayName()); - systemPrompt += QString( - "\n# Project source root: %1" - "\n# All new source files, headers, QML and CMake edits MUST be " - "created or modified under this directory. Use absolute paths " - "rooted here, or project-relative paths.") - .arg(project->projectDirectory().toUrlishString()); - - if (auto target = project->activeTarget()) { - if (auto buildConfig = target->activeBuildConfiguration()) { - systemPrompt - += QString( - "\n# Build output directory (compiler artifacts only — do NOT " - "create or edit source files here): %1") - .arg(buildConfig->buildDirectory().toUrlishString()); - } - } - - QString projectRules - = Context::RulesLoader::loadRulesForProject(project, Context::RulesContext::Chat); - - if (!projectRules.isEmpty()) { - systemPrompt += QString("\n# Project Rules\n\n") + projectRules; - } - } else { - systemPrompt += QString("\n# No active project in IDE"); - } - - if (m_skillsManager && Settings::skillsSettings().enableSkills()) { - QStringList projectSkillDirs; - if (project) { - Settings::ProjectSettings projectSettings(project); - projectSkillDirs = Settings::SkillsSettings::splitLines( - projectSettings.projectSkillDirs()); - } - m_skillsManager->configure( - project ? project->projectDirectory().toFSPathString() : QString(), - Settings::SkillsSettings::splitPaths( - Settings::skillsSettings().globalSkillRoots()), - projectSkillDirs); - - const QString alwaysOnSkills = m_skillsManager->alwaysOnBodies(); - if (!alwaysOnSkills.isEmpty()) - systemPrompt += QString("\n\n") + alwaysOnSkills; - - const QString skillsCatalog = m_skillsManager->catalogText(); - if (!skillsCatalog.isEmpty()) - systemPrompt += QString("\n\n") + skillsCatalog; - - static const QRegularExpression skillCommand( - QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)")); - QStringList invokedSkillNames; - auto skillMatch = skillCommand.globalMatch(message); - while (skillMatch.hasNext()) { - const QString skillName = skillMatch.next().captured(1); - if (invokedSkillNames.contains(skillName)) - continue; - const auto invokedSkill = m_skillsManager->findByName(skillName); - if (invokedSkill && !invokedSkill->body.isEmpty()) { - invokedSkillNames << skillName; - systemPrompt += QString("\n\n# Invoked Skill: %1\n\n%2") - .arg(invokedSkill->name, invokedSkill->body); - } - } - } - - if (!linkedFiles.isEmpty()) { - systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles); - } - context.systemPrompt = systemPrompt; - } - - const bool toolHistory = promptTemplate->supportsToolHistory(); - - QVector messages; - int toolCallMsgIdx = -1; - for (const auto &msg : m_chatModel->getChatHistory()) { - if (msg.role == ChatModel::ChatRole::Tool) { - if (!toolHistory || msg.toolName.isEmpty()) { - continue; - } - - if (toolCallMsgIdx < 0) { - LLMCore::Message assistantCall; - assistantCall.role = "assistant"; - messages.append(assistantCall); - toolCallMsgIdx = messages.size() - 1; - } - - LLMCore::ToolCall call; - call.id = msg.id; - call.name = msg.toolName; - call.arguments = msg.toolArguments; - messages[toolCallMsgIdx].toolCalls.append(call); - - LLMCore::Message toolResult; - toolResult.role = "tool"; - toolResult.toolCallId = msg.id; - toolResult.toolName = msg.toolName; - toolResult.content = msg.toolResult; - messages.append(toolResult); - continue; - } - - toolCallMsgIdx = -1; - - if (msg.role == ChatModel::ChatRole::FileEdit) { - continue; - } - - LLMCore::Message apiMessage; - apiMessage.role = msg.role == ChatModel::ChatRole::User ? "user" : "assistant"; - apiMessage.content = msg.content; - - if (!msg.attachments.isEmpty() && !m_chatFilePath.isEmpty()) { - apiMessage.content += "\n\nAttached files:"; - for (const auto &attachment : msg.attachments) { - QString fileContent = ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.content); - if (!fileContent.isEmpty()) { - QString decodedContent = QString::fromUtf8(QByteArray::fromBase64(fileContent.toUtf8())); - apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```") - .arg(attachment.filename, decodedContent); - } - } - } - - apiMessage.isThinking = (msg.role == ChatModel::ChatRole::Thinking); - apiMessage.isRedacted = msg.isRedacted; - apiMessage.signature = msg.signature; - - if (provider->capabilities().testFlag(Providers::ProviderCapability::Image) - && !m_chatFilePath.isEmpty() && !msg.images.isEmpty()) { - auto apiImages = loadImagesFromStorage(msg.images); - if (!apiImages.isEmpty()) { - apiMessage.images = apiImages; - } - } - - messages.append(apiMessage); - } - - if (!imageFiles.isEmpty() - && !provider->capabilities().testFlag(Providers::ProviderCapability::Image)) { - LOG_MESSAGE(QString("Provider %1 doesn't support images, %2 ignored") - .arg(provider->name(), QString::number(imageFiles.size()))); - } - - context.history = messages; - - QJsonObject payload{ - {"model", Settings::generalSettings().caModel()}, {"stream", true}}; - - provider->prepareRequest( - payload, - promptTemplate, - context, - LLMCore::RequestType::Chat, - useTools, - useThinking); - - provider->client()->setMaxToolContinuations( - Settings::toolsSettings().maxToolContinuations()); - - provider->client()->setTransferTimeout( - static_cast(Settings::generalSettings().requestTimeout() * 1000)); - - connect( - provider->client(), - &::LLMQore::BaseClient::chunkReceived, - this, - &ClientInterface::handlePartialResponse, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestCompleted, - this, - &ClientInterface::handleFullResponse, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestFinalized, - this, - &ClientInterface::handleRequestFinalized, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestFailed, - this, - &ClientInterface::handleRequestFailed, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::toolStarted, - this, - &ClientInterface::handleToolExecutionStarted, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::toolResultReady, - this, - &ClientInterface::handleToolExecutionCompleted, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::thinkingBlockReceived, - this, - &ClientInterface::handleThinkingBlockReceived, - Qt::UniqueConnection); - - const QString customEndpoint = Settings::generalSettings().caCustomEndpoint(); - const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint - : promptTemplate->endpoint(); - auto requestId - = provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint); - QJsonObject request{{"id", requestId}}; - - m_activeRequests[requestId] = {request, provider, !toolHistory}; - - emit requestStarted(requestId); - - if (provider->capabilities().testFlag(Providers::ProviderCapability::Tools) - && provider->toolsManager()) { - if (auto *todoTool = qobject_cast( - provider->toolsManager()->tool("todo_tool"))) { - todoTool->setCurrentSessionId(m_chatFilePath); - } - if (auto *historyTool = qobject_cast( - provider->toolsManager()->tool("read_original_history"))) { - historyTool->setCurrentSessionId(m_chatFilePath); - } - } -} - -void ClientInterface::clearMessages() -{ - const auto providerName = Settings::generalSettings().caProvider(); - auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName); - - if (provider && !m_chatFilePath.isEmpty() - && provider->capabilities().testFlag(Providers::ProviderCapability::Tools) - && provider->toolsManager()) { - if (auto *todoTool = qobject_cast( - provider->toolsManager()->tool("todo_tool"))) { - todoTool->clearSession(m_chatFilePath); - } - } - - m_chatModel->clear(); -} - -void ClientInterface::cancelRequest() -{ - QSet providers; - for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { - if (it.value().provider) { - providers.insert(it.value().provider); - } - } - - for (auto *provider : providers) { - disconnect(provider->client(), nullptr, this, nullptr); - } - - for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { - const RequestContext &ctx = it.value(); - if (ctx.provider) { - ctx.provider->cancelRequest(it.key()); - } - } - - m_activeRequests.clear(); - m_accumulatedResponses.clear(); - m_awaitingContinuation.clear(); - - LOG_MESSAGE("All requests cancelled and state cleared"); -} - -void ClientInterface::handleLLMResponse(const QString &response, const QJsonObject &request) -{ - const auto message = response.trimmed(); - - if (!message.isEmpty()) { - QString messageId = request["id"].toString(); - m_chatModel->addMessage(message, ChatModel::ChatRole::Assistant, messageId); - } -} - -QString ClientInterface::getCurrentFileContext() const -{ - auto currentEditor = Core::EditorManager::currentEditor(); - if (!currentEditor) { - LOG_MESSAGE("No active editor found"); - return QString(); - } - - auto textDocument = qobject_cast(currentEditor->document()); - if (!textDocument) { - LOG_MESSAGE("Current document is not a text document"); - return QString(); - } - - QString fileInfo = QString("Language: %1\nFile: %2\n\n") - .arg(textDocument->mimeType(), textDocument->filePath().toFSPathString()); - - QString content = textDocument->document()->toPlainText(); - - LOG_MESSAGE(QString("Got context from file: %1").arg(textDocument->filePath().toFSPathString())); - - return QString("Current file context:\n%1\nFile content:\n%2").arg(fileInfo, content); -} - -QString ClientInterface::getSystemPromptWithLinkedFiles( - const QString &basePrompt, const QList &linkedFiles) const -{ - QString updatedPrompt = basePrompt; - - if (!linkedFiles.isEmpty()) { - updatedPrompt += "\n\nLinked files for reference:\n"; - - auto contentFiles = m_contextManager->getContentFiles(linkedFiles); - for (const auto &file : contentFiles) { - updatedPrompt += QString("\nFile: %1\nContent:\n%2\n").arg(file.filename, file.content); - } - } - - return updatedPrompt; -} - -Context::ContextManager *ClientInterface::contextManager() const -{ - return m_contextManager; -} - -void ClientInterface::handlePartialResponse(const QString &requestId, const QString &partialText) -{ - auto it = m_activeRequests.find(requestId); - if (it == m_activeRequests.end()) - return; - - if (m_awaitingContinuation.remove(requestId)) { - m_accumulatedResponses[requestId].clear(); - LOG_MESSAGE( - QString("Cleared accumulated responses for continuation request %1").arg(requestId)); - } - - m_accumulatedResponses[requestId] += partialText; - - const RequestContext &ctx = it.value(); - handleLLMResponse(m_accumulatedResponses[requestId], ctx.originalRequest); -} - -void ClientInterface::handleFullResponse(const QString &requestId, const QString &fullText) -{ - auto it = m_activeRequests.find(requestId); - if (it == m_activeRequests.end()) - return; - - const RequestContext &ctx = it.value(); - - QString finalText = !fullText.isEmpty() ? fullText : m_accumulatedResponses[requestId]; - - QString applyError; - bool applySuccess - = Context::ChangesManager::instance().applyPendingEditsForRequest(requestId, &applyError); - - if (!applySuccess) { - LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2") - .arg(requestId, applyError)); - } - - LOG_MESSAGE( - "Message completed. Final response for message " + ctx.originalRequest["id"].toString() - + ": " + finalText); - emit messageReceivedCompletely(); - - m_activeRequests.erase(it); - m_accumulatedResponses.remove(requestId); - m_awaitingContinuation.remove(requestId); -} - -void ClientInterface::handleRequestFinalized( - const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info) -{ - if (!m_activeRequests.contains(requestId)) - return; - if (!info.usage) - return; - - const auto &u = *info.usage; - m_chatModel->setMessageUsage( - requestId, u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens); - - emit messageUsageReceived( - u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens); - - LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5") - .arg(requestId) - .arg(u.promptTokens) - .arg(u.completionTokens) - .arg(u.cachedPromptTokens) - .arg(u.reasoningTokens)); -} - -void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error) -{ - auto it = m_activeRequests.find(requestId); - if (it == m_activeRequests.end()) - return; - - LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error)); - emit errorOccurred(error); - - m_activeRequests.erase(it); - m_accumulatedResponses.remove(requestId); - m_awaitingContinuation.remove(requestId); -} - -void ClientInterface::handleThinkingBlockReceived( - const QString &requestId, const QString &thinking, const QString &signature) -{ - if (!m_activeRequests.contains(requestId)) { - LOG_MESSAGE(QString("Ignoring thinking block for non-chat request: %1").arg(requestId)); - return; - } - - if (m_awaitingContinuation.remove(requestId)) { - m_accumulatedResponses[requestId].clear(); - LOG_MESSAGE( - QString("Cleared accumulated responses for continuation request %1").arg(requestId)); - } - - if (thinking.isEmpty()) { - m_chatModel->addRedactedThinkingBlock(requestId, signature); - } else { - m_chatModel->addThinkingBlock(requestId, thinking, signature); - } -} - -void ClientInterface::handleToolExecutionStarted( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QJsonObject &arguments) -{ - const auto requestIt = m_activeRequests.constFind(requestId); - if (requestIt == m_activeRequests.constEnd()) { - LOG_MESSAGE(QString("Ignoring tool execution start for non-chat request: %1").arg(requestId)); - return; - } - - if (requestIt->dropPreToolText) { - m_chatModel->dropTrailingAssistantMessage(requestId); - } - m_chatModel->addToolExecutionStatus(requestId, toolId, toolName, arguments); - m_awaitingContinuation.insert(requestId); -} - -void ClientInterface::handleToolExecutionCompleted( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QString &toolOutput) -{ - if (!m_activeRequests.contains(requestId)) { - LOG_MESSAGE(QString("Ignoring tool execution result for non-chat request: %1").arg(requestId)); - return; - } - - m_chatModel->updateToolResult(requestId, toolId, toolName, toolOutput); -} - -bool ClientInterface::isImageFile(const QString &filePath) const -{ - static const QSet imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}; - - QFileInfo fileInfo(filePath); - QString extension = fileInfo.suffix().toLower(); - - return imageExtensions.contains(extension); -} - -QString ClientInterface::getMediaTypeForImage(const QString &filePath) const -{ - static const QHash mediaTypes - = {{"png", "image/png"}, - {"jpg", "image/jpeg"}, - {"jpeg", "image/jpeg"}, - {"gif", "image/gif"}, - {"webp", "image/webp"}, - {"bmp", "image/bmp"}, - {"svg", "image/svg+xml"}}; - - QFileInfo fileInfo(filePath); - QString extension = fileInfo.suffix().toLower(); - - if (mediaTypes.contains(extension)) { - return mediaTypes[extension]; - } - - QMimeDatabase mimeDb; - QMimeType mimeType = mimeDb.mimeTypeForFile(filePath); - return mimeType.name(); -} - -QString ClientInterface::encodeImageToBase64(const QString &filePath) const -{ - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly)) { - LOG_MESSAGE(QString("Failed to open image file: %1").arg(filePath)); - return QString(); - } - - QByteArray imageData = file.readAll(); - file.close(); - - return imageData.toBase64(); -} - -QVector ClientInterface::loadImagesFromStorage( - const QList &storedImages) const -{ - QVector apiImages; - - for (const auto &storedImage : storedImages) { - QString base64Data - = ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath); - if (base64Data.isEmpty()) { - LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath)); - continue; - } - - LLMCore::ImageAttachment apiImage; - apiImage.data = base64Data; - apiImage.mediaType = storedImage.mediaType; - apiImage.isUrl = false; - - apiImages.append(apiImage); - } - - return apiImages; -} - -void ClientInterface::setChatFilePath(const QString &filePath) -{ - if (!m_chatFilePath.isEmpty() && m_chatFilePath != filePath) { - const auto providerName = Settings::generalSettings().caProvider(); - auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName); - - if (provider - && provider->capabilities().testFlag(Providers::ProviderCapability::Tools) - && provider->toolsManager()) { - if (auto *todoTool = qobject_cast( - provider->toolsManager()->tool("todo_tool"))) { - todoTool->clearSession(m_chatFilePath); - } - } - } - - m_chatFilePath = filePath; - m_chatModel->setChatFilePath(filePath); -} - -QString ClientInterface::chatFilePath() const -{ - return m_chatFilePath; -} - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ClientInterface.hpp b/sources/ChatView/ClientInterface.hpp deleted file mode 100644 index c272f3d..0000000 --- a/sources/ChatView/ClientInterface.hpp +++ /dev/null @@ -1,102 +0,0 @@ -// 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 -#include - -#include "ChatModel.hpp" -#include "providers/Provider.hpp" -#include "templates/IPromptProvider.hpp" -#include -#include - -namespace QodeAssist::Skills { -class SkillsManager; -} - -namespace QodeAssist::Chat { - -class ClientInterface : public QObject -{ - Q_OBJECT - -public: - explicit ClientInterface( - ChatModel *chatModel, Templates::IPromptProvider *promptProvider, QObject *parent = nullptr); - ~ClientInterface(); - - void setSkillsManager(Skills::SkillsManager *skillsManager); - - void sendMessage( - const QString &message, - const QList &attachments = {}, - const QList &linkedFiles = {}, - bool useTools = false, - bool useThinking = false); - void clearMessages(); - void cancelRequest(); - - Context::ContextManager *contextManager() const; - - void setChatFilePath(const QString &filePath); - QString chatFilePath() const; - -signals: - void errorOccurred(const QString &error); - void messageReceivedCompletely(); - void requestStarted(const QString &requestId); - void messageUsageReceived( - int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens); - -private slots: - void handlePartialResponse(const QString &requestId, const QString &partialText); - void handleFullResponse(const QString &requestId, const QString &fullText); - void handleRequestFinalized(const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info); - void handleRequestFailed(const QString &requestId, const QString &error); - void handleThinkingBlockReceived( - const QString &requestId, const QString &thinking, const QString &signature); - void handleToolExecutionStarted( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QJsonObject &arguments); - void handleToolExecutionCompleted( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QString &toolOutput); - -private: - void handleLLMResponse(const QString &response, const QJsonObject &request); - QString getCurrentFileContext() const; - QString getSystemPromptWithLinkedFiles( - const QString &basePrompt, const QList &linkedFiles) const; - bool isImageFile(const QString &filePath) const; - QString getMediaTypeForImage(const QString &filePath) const; - QString encodeImageToBase64(const QString &filePath) const; - QVector loadImagesFromStorage(const QList &storedImages) const; - - struct RequestContext - { - QJsonObject originalRequest; - Providers::Provider *provider; - bool dropPreToolText = false; - }; - - Templates::IPromptProvider *m_promptProvider = nullptr; - ChatModel *m_chatModel; - Context::ContextManager *m_contextManager; - Skills::SkillsManager *m_skillsManager = nullptr; - QString m_chatFilePath; - - QHash m_activeRequests; - QHash m_accumulatedResponses; - QSet m_awaitingContinuation; -}; - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/FileEditController.cpp b/sources/ChatView/FileEditController.cpp index 689c457..208a7bf 100644 --- a/sources/ChatView/FileEditController.cpp +++ b/sources/ChatView/FileEditController.cpp @@ -10,15 +10,13 @@ #include #include -#include "ChatModel.hpp" #include "Logger.hpp" #include "context/ChangesManager.h" namespace QodeAssist::Chat { -FileEditController::FileEditController(ChatModel *chatModel, QObject *parent) +FileEditController::FileEditController(QObject *parent) : QObject(parent) - , m_chatModel(chatModel) { auto &changes = Context::ChangesManager::instance(); connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) { @@ -80,7 +78,7 @@ void FileEditController::applyFileEdit(const QString &editId) LOG_MESSAGE(QString("Applying file edit: %1").arg(editId)); if (Context::ChangesManager::instance().applyFileEdit(editId)) { emit infoMessage(QString("File edit applied successfully")); - updateFileEditStatus(editId, "applied"); + updateStats(); } else { auto edit = Context::ChangesManager::instance().getFileEdit(editId); emit errorOccurred( @@ -95,7 +93,7 @@ void FileEditController::rejectFileEdit(const QString &editId) LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId)); if (Context::ChangesManager::instance().rejectFileEdit(editId)) { emit infoMessage(QString("File edit rejected")); - updateFileEditStatus(editId, "rejected"); + updateStats(); } else { auto edit = Context::ChangesManager::instance().getFileEdit(editId); emit errorOccurred( @@ -110,7 +108,7 @@ void FileEditController::undoFileEdit(const QString &editId) LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId)); if (Context::ChangesManager::instance().undoFileEdit(editId)) { emit infoMessage(QString("File edit undone successfully")); - updateFileEditStatus(editId, "rejected"); + updateStats(); } else { auto edit = Context::ChangesManager::instance().getFileEdit(editId); emit errorOccurred( @@ -163,44 +161,6 @@ void FileEditController::openFileEditInEditor(const QString &editId) LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath)); } -void FileEditController::updateFileEditStatus(const QString &editId, const QString &status) -{ - auto messages = m_chatModel->getChatHistory(); - for (int i = 0; i < messages.size(); ++i) { - if (messages[i].role == Chat::ChatModel::FileEdit && messages[i].id == editId) { - QString content = messages[i].content; - - const QString marker = "QODEASSIST_FILE_EDIT:"; - int markerPos = content.indexOf(marker); - - QString jsonStr = content; - if (markerPos >= 0) { - jsonStr = content.mid(markerPos + marker.length()); - } - - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); - if (doc.isObject()) { - QJsonObject obj = doc.object(); - obj["status"] = status; - - auto edit = Context::ChangesManager::instance().getFileEdit(editId); - if (!edit.statusMessage.isEmpty()) { - obj["status_message"] = edit.statusMessage; - } - - QString updatedContent = marker - + QString::fromUtf8( - QJsonDocument(obj).toJson(QJsonDocument::Compact)); - m_chatModel->updateMessageContent(editId, updatedContent); - LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status)); - } - break; - } - } - - updateStats(); -} - void FileEditController::applyAllForCurrentMessage() { if (m_currentRequestId.isEmpty()) { @@ -223,13 +183,6 @@ void FileEditController::applyAllForCurrentMessage() : QString("Failed to apply some file edits:\n%1").arg(errorMsg)); } - auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId); - for (const auto &edit : edits) { - if (edit.status == Context::ChangesManager::Applied) { - updateFileEditStatus(edit.editId, "applied"); - } - } - updateStats(); } @@ -255,13 +208,6 @@ void FileEditController::undoAllForCurrentMessage() : QString("Failed to undo some file edits:\n%1").arg(errorMsg)); } - auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId); - for (const auto &edit : edits) { - if (edit.status == Context::ChangesManager::Rejected) { - updateFileEditStatus(edit.editId, "rejected"); - } - } - updateStats(); } diff --git a/sources/ChatView/FileEditController.hpp b/sources/ChatView/FileEditController.hpp index 6e7e261..691278a 100644 --- a/sources/ChatView/FileEditController.hpp +++ b/sources/ChatView/FileEditController.hpp @@ -9,14 +9,12 @@ namespace QodeAssist::Chat { -class ChatModel; - class FileEditController : public QObject { Q_OBJECT public: - explicit FileEditController(ChatModel *chatModel, QObject *parent = nullptr); + explicit FileEditController(QObject *parent = nullptr); void setCurrentRequestId(const QString &requestId); void clearCurrentRequestId(); @@ -41,9 +39,6 @@ signals: void errorOccurred(const QString &error); private: - void updateFileEditStatus(const QString &editId, const QString &status); - - ChatModel *m_chatModel; QString m_currentRequestId; int m_totalEdits{0}; int m_appliedEdits{0}; diff --git a/sources/ChatView/InputTokenCounter.cpp b/sources/ChatView/InputTokenCounter.cpp index d4af4ab..0ebd08d 100644 --- a/sources/ChatView/InputTokenCounter.cpp +++ b/sources/ChatView/InputTokenCounter.cpp @@ -7,25 +7,27 @@ #include #include +#include +#include #include #include #include #include "ChatAssistantSettings.hpp" -#include "ChatModel.hpp" #include "GeneralSettings.hpp" #include "Logger.hpp" #include "providers/ProvidersManager.hpp" #include "context/ContextManager.hpp" #include "context/TokenUtils.hpp" +#include "session/Session.hpp" namespace QodeAssist::Chat { InputTokenCounter::InputTokenCounter( - ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent) + Session::Session *session, Context::ContextManager *contextManager, QObject *parent) : QObject(parent) - , m_chatModel(chatModel) + , m_session(session) , m_contextManager(contextManager) { auto &settings = Settings::chatAssistantSettings(); @@ -47,10 +49,19 @@ InputTokenCounter::InputTokenCounter( recompute(); }); + m_recomputeTimer.setSingleShot(true); + m_recomputeTimer.setInterval(150); + connect(&m_recomputeTimer, &QTimer::timeout, this, &InputTokenCounter::recompute); + rewireToolsChangedConnection(); recompute(); } +void InputTokenCounter::recomputeSoon() +{ + m_recomputeTimer.start(); +} + int InputTokenCounter::inputTokens() const { return m_inputTokens; @@ -59,7 +70,7 @@ int InputTokenCounter::inputTokens() const void InputTokenCounter::setMessage(const QString &message) { m_messageTokens = Context::TokenUtils::estimateTokens(message); - recompute(); + recomputeSoon(); } void InputTokenCounter::setAttachments(const QStringList &attachments) @@ -92,6 +103,37 @@ void InputTokenCounter::rewireToolsChangedConnection() tm, &::LLMQore::ToolRegistry::toolsChanged, this, &InputTokenCounter::recompute); } +int InputTokenCounter::estimateFileTokens(const QStringList &paths) +{ + if (paths.isEmpty()) + return 0; + + int total = 0; + QStringList uncached; + + for (const QString &path : paths) { + const QDateTime modified = QFileInfo(path).lastModified(); + const auto cached = m_fileTokens.constFind(path); + if (cached != m_fileTokens.constEnd() && cached->modified == modified) { + total += cached->tokens; + continue; + } + uncached.append(path); + } + + if (m_fileTokens.size() > 256) + m_fileTokens.clear(); + + for (const QString &path : std::as_const(uncached)) { + const int tokens = Context::TokenUtils::estimateFilesTokens( + m_contextManager->getContentFiles({path})); + total += tokens; + m_fileTokens.insert(path, {QFileInfo(path).lastModified(), tokens}); + } + + return total; +} + void InputTokenCounter::recompute() { int inputTokens = m_messageTokens; @@ -115,25 +157,20 @@ void InputTokenCounter::recompute() if (!m_attachments.isEmpty()) { QStringList textPaths; inputTokens += splitImageEstimate(m_attachments, textPaths); - if (!textPaths.isEmpty()) { - auto attachFiles = m_contextManager->getContentFiles(textPaths); - inputTokens += Context::TokenUtils::estimateFilesTokens(attachFiles); - } + inputTokens += estimateFileTokens(textPaths); } if (!m_linkedFiles.isEmpty()) { QStringList textPaths; inputTokens += splitImageEstimate(m_linkedFiles, textPaths); - if (!textPaths.isEmpty()) { - auto linkFiles = m_contextManager->getContentFiles(textPaths); - inputTokens += Context::TokenUtils::estimateFilesTokens(linkFiles); - } + inputTokens += estimateFileTokens(textPaths); } - const auto &history = m_chatModel->getChatHistory(); - for (const auto &message : history) { - inputTokens += Context::TokenUtils::estimateTokens(message.content); - inputTokens += 4; // + role + if (m_session) { + for (const Session::MessageRow &row : m_session->rows()) { + inputTokens += Context::TokenUtils::estimateTokens(row.content); + inputTokens += 4; // + role + } } if (settings.enableChatTools()) { @@ -157,6 +194,11 @@ void InputTokenCounter::recompute() void InputTokenCounter::recordSent() { + if (m_recomputeTimer.isActive()) { + m_recomputeTimer.stop(); + recompute(); + } + m_lastSentEstimate = m_calibrationFactor > 0.0 ? static_cast(m_inputTokens / m_calibrationFactor) : m_inputTokens; diff --git a/sources/ChatView/InputTokenCounter.hpp b/sources/ChatView/InputTokenCounter.hpp index 2e1ac83..c75749b 100644 --- a/sources/ChatView/InputTokenCounter.hpp +++ b/sources/ChatView/InputTokenCounter.hpp @@ -4,16 +4,22 @@ #pragma once +#include +#include #include +#include #include +#include namespace QodeAssist::Context { class ContextManager; } -namespace QodeAssist::Chat { +namespace QodeAssist::Session { +class Session; +} -class ChatModel; +namespace QodeAssist::Chat { class InputTokenCounter : public QObject { @@ -21,7 +27,9 @@ class InputTokenCounter : public QObject public: InputTokenCounter( - ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent = nullptr); + Session::Session *session, + Context::ContextManager *contextManager, + QObject *parent = nullptr); int inputTokens() const; @@ -29,6 +37,7 @@ public: void setAttachments(const QStringList &attachments); void setLinkedFiles(const QStringList &linkedFiles); void recompute(); + void recomputeSoon(); void recordSent(); void recordServerUsage(int promptTokens); @@ -37,11 +46,20 @@ signals: void inputTokensChanged(); private: - void rewireToolsChangedConnection(); + struct CachedFileTokens + { + QDateTime modified; + int tokens = 0; + }; - ChatModel *m_chatModel; + void rewireToolsChangedConnection(); + int estimateFileTokens(const QStringList &paths); + + QPointer m_session; Context::ContextManager *m_contextManager; QMetaObject::Connection m_toolsChangedConn; + QTimer m_recomputeTimer; + QHash m_fileTokens; QStringList m_attachments; QStringList m_linkedFiles; diff --git a/sources/ChatView/LlmChatBackend.cpp b/sources/ChatView/LlmChatBackend.cpp new file mode 100644 index 0000000..fc221b2 --- /dev/null +++ b/sources/ChatView/LlmChatBackend.cpp @@ -0,0 +1,420 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "LlmChatBackend.hpp" + +#include + +#include + +#include "ChatSerializer.hpp" +#include "llmcore/ContextData.hpp" +#include "logger/Logger.hpp" +#include "providers/ProvidersManager.hpp" +#include "session/FileEditPayload.hpp" +#include "session/HistoryProjection.hpp" +#include "settings/GeneralSettings.hpp" +#include "settings/ToolsSettings.hpp" +#include "tools/ReadOriginalHistoryTool.hpp" +#include "tools/TodoTool.hpp" + +namespace QodeAssist::Chat { + +LlmChatBackend::LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent) + : Session::ChatBackend(parent) + , m_promptProvider(promptProvider) +{} + +LlmChatBackend::~LlmChatBackend() +{ + cancel(); +} + +void LlmChatBackend::sendTurn(const Session::TurnRequest &request) +{ + cancel(); + + if (!request.history) { + emit sessionEvent( + Session::TurnFailed{.turnId = {}, .error = tr("Chat turn sent without a conversation")}); + return; + } + + const auto providerName = Settings::generalSettings().caProvider(); + auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName); + + if (!provider) { + const QString error = tr("No provider found with name: %1").arg(providerName); + LOG_MESSAGE(error); + emit sessionEvent(Session::TurnFailed{.turnId = {}, .error = error}); + return; + } + + const auto templateName = Settings::generalSettings().caTemplate(); + auto *promptTemplate = m_promptProvider->getTemplateByName(templateName); + + if (!promptTemplate) { + const QString error = tr("No template found with name: %1").arg(templateName); + LOG_MESSAGE(error); + emit sessionEvent(Session::TurnFailed{.turnId = {}, .error = error}); + return; + } + + LLMCore::ContextData context; + if (request.context) + context.systemPrompt = Session::renderSystemPrompt(*request.context); + context.history = renderHistory(*request.history, provider, promptTemplate); + + QJsonObject payload{{"model", Settings::generalSettings().caModel()}, {"stream", true}}; + + provider->prepareRequest( + payload, + promptTemplate, + context, + LLMCore::RequestType::Chat, + request.options.useTools, + request.options.useThinking); + + provider->client()->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations()); + provider->client()->setTransferTimeout( + static_cast(Settings::generalSettings().requestTimeout() * 1000)); + + connectClient(provider); + + const QString customEndpoint = Settings::generalSettings().caCustomEndpoint(); + const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint + : promptTemplate->endpoint(); + + m_provider = provider; + m_dropPreToolText = !promptTemplate->supportsToolHistory(); + m_requestId + = provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint); + + emit sessionEvent(Session::TurnStarted{.turnId = m_requestId}); + + bindToolSessions(provider); +} + +void LlmChatBackend::cancel() +{ + if (!m_provider) + return; + + auto *provider = m_provider; + const QString requestId = m_requestId; + + releaseRequest(); + + if (!requestId.isEmpty()) + provider->cancelRequest(requestId); + + LOG_MESSAGE("Chat request cancelled and state cleared"); +} + +void LlmChatBackend::releaseRequest() +{ + if (m_provider) + disconnect(m_provider->client(), nullptr, this, nullptr); + + m_provider = nullptr; + m_requestId.clear(); + m_dropPreToolText = false; +} + +void LlmChatBackend::setChatFilePath(const QString &filePath) +{ + m_chatFilePath = filePath; +} + +void LlmChatBackend::clearToolSession(const QString &filePath) +{ + if (filePath.isEmpty()) + return; + + const auto providerName = Settings::generalSettings().caProvider(); + auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName); + + if (!provider || !provider->capabilities().testFlag(Providers::ProviderCapability::Tools) + || !provider->toolsManager()) { + return; + } + + if (auto *todoTool = qobject_cast( + provider->toolsManager()->tool("todo_tool"))) { + todoTool->clearSession(filePath); + } +} + +void LlmChatBackend::connectClient(Providers::Provider *provider) +{ + auto *client = provider->client(); + + connect( + client, + &::LLMQore::BaseClient::chunkReceived, + this, + &LlmChatBackend::handleChunk, + Qt::UniqueConnection); + connect( + client, + &::LLMQore::BaseClient::requestCompleted, + this, + &LlmChatBackend::handleCompleted, + Qt::UniqueConnection); + connect( + client, + &::LLMQore::BaseClient::requestFinalized, + this, + &LlmChatBackend::handleFinalized, + Qt::UniqueConnection); + connect( + client, + &::LLMQore::BaseClient::requestFailed, + this, + &LlmChatBackend::handleFailed, + Qt::UniqueConnection); + connect( + client, + &::LLMQore::BaseClient::toolStarted, + this, + &LlmChatBackend::handleToolStarted, + Qt::UniqueConnection); + connect( + client, + &::LLMQore::BaseClient::toolResultReady, + this, + &LlmChatBackend::handleToolResult, + Qt::UniqueConnection); + connect( + client, + &::LLMQore::BaseClient::thinkingBlockReceived, + this, + &LlmChatBackend::handleThinkingBlock, + Qt::UniqueConnection); +} + +void LlmChatBackend::bindToolSessions(Providers::Provider *provider) +{ + if (!provider->capabilities().testFlag(Providers::ProviderCapability::Tools) + || !provider->toolsManager()) { + return; + } + + if (auto *todoTool = qobject_cast( + provider->toolsManager()->tool("todo_tool"))) { + todoTool->setCurrentSessionId(m_chatFilePath); + } + if (auto *historyTool = qobject_cast( + provider->toolsManager()->tool("read_original_history"))) { + historyTool->setCurrentSessionId(m_chatFilePath); + } +} + +QVector LlmChatBackend::renderHistory( + const Session::ConversationHistory &history, + Providers::Provider *provider, + Templates::PromptTemplate *promptTemplate) const +{ + const bool toolHistory = promptTemplate->supportsToolHistory(); + + QVector messages; + int toolCallMsgIdx = -1; + + for (const Session::MessageRow &row : Session::projectToRows(history)) { + if (row.kind == Session::RowKind::Tool) { + if (!toolHistory || row.toolName.isEmpty()) + continue; + + if (toolCallMsgIdx < 0) { + LLMCore::Message assistantCall; + assistantCall.role = "assistant"; + messages.append(assistantCall); + toolCallMsgIdx = messages.size() - 1; + } + + LLMCore::ToolCall call; + call.id = row.id; + call.name = row.toolName; + call.arguments = row.toolArguments; + messages[toolCallMsgIdx].toolCalls.append(call); + + LLMCore::Message toolResult; + toolResult.role = "tool"; + toolResult.toolCallId = row.id; + toolResult.toolName = row.toolName; + toolResult.content = row.toolResult; + messages.append(toolResult); + continue; + } + + toolCallMsgIdx = -1; + + if (row.kind == Session::RowKind::FileEdit) + continue; + + LLMCore::Message apiMessage; + apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant"; + apiMessage.content = row.content; + + if (!row.attachments.isEmpty() && !m_chatFilePath.isEmpty()) { + apiMessage.content += "\n\nAttached files:"; + for (const Session::AttachmentBlock &attachment : row.attachments) { + const QString fileContent + = ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.storedPath); + if (fileContent.isEmpty()) + continue; + + const QString decodedContent = QString::fromUtf8( + QByteArray::fromBase64(fileContent.toUtf8())); + apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```") + .arg(attachment.fileName, decodedContent); + } + } + + apiMessage.isThinking = row.kind == Session::RowKind::Thinking; + apiMessage.isRedacted = row.redacted; + apiMessage.signature = row.signature; + + if (provider->capabilities().testFlag(Providers::ProviderCapability::Image) + && !m_chatFilePath.isEmpty() && !row.images.isEmpty()) { + const auto apiImages = loadImagesFromStorage(row.images); + if (!apiImages.isEmpty()) + apiMessage.images = apiImages; + } + + messages.append(apiMessage); + } + + return messages; +} + +QVector LlmChatBackend::loadImagesFromStorage( + const QList &storedImages) const +{ + QVector apiImages; + + for (const Session::ImageBlock &storedImage : storedImages) { + const QString base64Data + = ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath); + if (base64Data.isEmpty()) { + LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath)); + continue; + } + + LLMCore::ImageAttachment apiImage; + apiImage.data = base64Data; + apiImage.mediaType = storedImage.mediaType; + apiImage.isUrl = false; + + apiImages.append(apiImage); + } + + return apiImages; +} + +void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk) +{ + if (requestId != m_requestId) + return; + + emit sessionEvent(Session::TextDelta{.turnId = requestId, .text = chunk}); +} + +void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fullText) +{ + if (requestId != m_requestId) + return; + + LOG_MESSAGE( + QString("Chat request %1 completed, %2 characters").arg(requestId).arg(fullText.length())); + + releaseRequest(); + + emit sessionEvent(Session::TurnCompleted{.turnId = requestId}); +} + +void LlmChatBackend::handleFinalized( + const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info) +{ + if (requestId != m_requestId || !info.usage) + return; + + const auto &usage = *info.usage; + + LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5") + .arg(requestId) + .arg(usage.promptTokens) + .arg(usage.completionTokens) + .arg(usage.cachedPromptTokens) + .arg(usage.reasoningTokens)); + + emit sessionEvent( + Session::UsageReported{ + .turnId = requestId, + .usage = Session::Usage{ + .promptTokens = usage.promptTokens, + .completionTokens = usage.completionTokens, + .cachedPromptTokens = usage.cachedPromptTokens, + .reasoningTokens = usage.reasoningTokens}}); +} + +void LlmChatBackend::handleFailed(const QString &requestId, const QString &error) +{ + if (requestId != m_requestId) + return; + + LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error)); + + releaseRequest(); + + emit sessionEvent(Session::TurnFailed{.turnId = requestId, .error = error}); +} + +void LlmChatBackend::handleThinkingBlock( + const QString &requestId, const QString &thinking, const QString &signature) +{ + if (requestId != m_requestId) + return; + + emit sessionEvent( + Session::ThinkingReceived{ + .turnId = requestId, + .text = thinking, + .signature = signature, + .redacted = thinking.isEmpty()}); +} + +void LlmChatBackend::handleToolStarted( + const QString &requestId, + const QString &toolId, + const QString &toolName, + const QJsonObject &arguments) +{ + if (requestId != m_requestId) + return; + + emit sessionEvent( + Session::ToolCallStarted{ + .turnId = requestId, + .toolId = toolId, + .name = toolName, + .arguments = arguments, + .dropPrecedingText = m_dropPreToolText}); +} + +void LlmChatBackend::handleToolResult( + const QString &requestId, + const QString &toolId, + const QString &toolName, + const QString &toolOutput) +{ + if (requestId != m_requestId) + return; + + emit sessionEvent( + Session::ToolCallCompleted{ + .turnId = requestId, .toolId = toolId, .name = toolName, .result = toolOutput}); +} + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/LlmChatBackend.hpp b/sources/ChatView/LlmChatBackend.hpp new file mode 100644 index 0000000..104e3c2 --- /dev/null +++ b/sources/ChatView/LlmChatBackend.hpp @@ -0,0 +1,69 @@ +// 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 +#include + +#include + +#include "providers/Provider.hpp" +#include "session/ChatBackend.hpp" +#include "templates/IPromptProvider.hpp" + +namespace QodeAssist::Chat { + +class LlmChatBackend : public Session::ChatBackend +{ + Q_OBJECT + +public: + explicit LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent = nullptr); + ~LlmChatBackend() override; + + void sendTurn(const Session::TurnRequest &request) override; + void cancel() override; + + void setChatFilePath(const QString &filePath); + void clearToolSession(const QString &filePath); + +private: + void connectClient(Providers::Provider *provider); + void releaseRequest(); + void bindToolSessions(Providers::Provider *provider); + QVector renderHistory( + const Session::ConversationHistory &history, + Providers::Provider *provider, + Templates::PromptTemplate *promptTemplate) const; + QVector loadImagesFromStorage( + const QList &storedImages) const; + + void handleChunk(const QString &requestId, const QString &chunk); + void handleCompleted(const QString &requestId, const QString &fullText); + void handleFinalized( + const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info); + void handleFailed(const QString &requestId, const QString &error); + void handleThinkingBlock( + const QString &requestId, const QString &thinking, const QString &signature); + void handleToolStarted( + const QString &requestId, + const QString &toolId, + const QString &toolName, + const QJsonObject &arguments); + void handleToolResult( + const QString &requestId, + const QString &toolId, + const QString &toolName, + const QString &toolOutput); + + Templates::IPromptProvider *m_promptProvider = nullptr; + QString m_chatFilePath; + + Providers::Provider *m_provider = nullptr; + QString m_requestId; + bool m_dropPreToolText = false; +}; + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/TurnContextAdapters.cpp b/sources/ChatView/TurnContextAdapters.cpp new file mode 100644 index 0000000..a60ad01 --- /dev/null +++ b/sources/ChatView/TurnContextAdapters.cpp @@ -0,0 +1,106 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "TurnContextAdapters.hpp" + +#include +#include +#include + +#include "ProjectSettings.hpp" +#include "SkillsSettings.hpp" +#include "context/ContextManager.hpp" +#include "context/RulesLoader.hpp" +#include "skills/SkillsManager.hpp" + +namespace QodeAssist::Chat { + +ProjectContextQtCreator::ProjectContextQtCreator(ProjectExplorer::Project *project) + : m_project(project) +{} + +Session::ProjectInfo ProjectContextQtCreator::projectInfo() const +{ + if (!m_project) + return {}; + + Session::ProjectInfo info; + info.available = true; + info.displayName = m_project->displayName(); + info.sourceRoot = m_project->projectDirectory().toUrlishString(); + + if (auto target = m_project->activeTarget()) { + if (auto buildConfig = target->activeBuildConfiguration()) + info.buildDirectory = buildConfig->buildDirectory().toUrlishString(); + } + + return info; +} + +QString ProjectContextQtCreator::projectRules() const +{ + if (!m_project) + return {}; + + return Context::RulesLoader::loadRulesForProject(m_project, Context::RulesContext::Chat); +} + +SkillsContextQtCreator::SkillsContextQtCreator( + Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project) + : m_skillsManager(skillsManager) +{ + QStringList projectSkillDirs; + if (project) { + Settings::ProjectSettings projectSettings(project); + projectSkillDirs = Settings::SkillsSettings::splitLines(projectSettings.projectSkillDirs()); + } + + m_skillsManager->configure( + project ? project->projectDirectory().toFSPathString() : QString(), + Settings::SkillsSettings::splitPaths(Settings::skillsSettings().globalSkillRoots()), + projectSkillDirs); +} + +QString SkillsContextQtCreator::alwaysOnBodies() const +{ + return m_skillsManager->alwaysOnBodies(); +} + +QString SkillsContextQtCreator::catalogText() const +{ + return m_skillsManager->catalogText(); +} + +std::optional SkillsContextQtCreator::findSkill(const QString &name) const +{ + const auto skill = m_skillsManager->findByName(name); + if (!skill) + return std::nullopt; + + return Session::InvokedSkill{skill->name, skill->body}; +} + +LinkedFilesQtCreator::LinkedFilesQtCreator(Context::ContextManager *contextManager) + : m_contextManager(contextManager) +{} + +QList LinkedFilesQtCreator::readFiles(const QList &paths) const +{ + QList files; + for (const auto &file : m_contextManager->getContentFiles(paths)) + files.append(Session::LinkedFile{file.filename, file.content}); + + return files; +} + +std::unique_ptr makeSkillsContext( + Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project) +{ + if (!skillsManager || !Settings::skillsSettings().enableSkills()) + return nullptr; + + return std::make_unique(skillsManager, project); +} + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/TurnContextAdapters.hpp b/sources/ChatView/TurnContextAdapters.hpp new file mode 100644 index 0000000..fdc96b3 --- /dev/null +++ b/sources/ChatView/TurnContextAdapters.hpp @@ -0,0 +1,64 @@ +// 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 + +#include "session/TurnContextPorts.hpp" + +namespace ProjectExplorer { +class Project; +} + +namespace QodeAssist::Context { +class ContextManager; +} + +namespace QodeAssist::Skills { +class SkillsManager; +} + +namespace QodeAssist::Chat { + +class ProjectContextQtCreator : public Session::IProjectContextPort +{ +public: + explicit ProjectContextQtCreator(ProjectExplorer::Project *project); + + Session::ProjectInfo projectInfo() const override; + QString projectRules() const override; + +private: + ProjectExplorer::Project *m_project = nullptr; +}; + +class SkillsContextQtCreator : public Session::ISkillsContextPort +{ +public: + SkillsContextQtCreator(Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project); + + QString alwaysOnBodies() const override; + QString catalogText() const override; + std::optional findSkill(const QString &name) const override; + +private: + Skills::SkillsManager *m_skillsManager = nullptr; +}; + +class LinkedFilesQtCreator : public Session::ILinkedFilesPort +{ +public: + explicit LinkedFilesQtCreator(Context::ContextManager *contextManager); + + QList readFiles(const QList &paths) const override; + +private: + Context::ContextManager *m_contextManager = nullptr; +}; + +std::unique_ptr makeSkillsContext( + Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project); + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/qml/RootItem.qml b/sources/ChatView/qml/RootItem.qml index 585af60..58f615a 100644 --- a/sources/ChatView/qml/RootItem.qml +++ b/sources/ChatView/qml/RootItem.qml @@ -343,7 +343,7 @@ ChatRootView { onResetChatToMessage: function(idx) { messageInput.text = model.content messageInput.cursorPosition = model.content.length - root.chatModel.resetModelTo(idx) + root.resetChatToMessage(idx) } onOpenFileRequested: function(filePath) { diff --git a/sources/Session/CMakeLists.txt b/sources/Session/CMakeLists.txt deleted file mode 100644 index 0d4329b..0000000 --- a/sources/Session/CMakeLists.txt +++ /dev/null @@ -1,23 +0,0 @@ -add_library(Session STATIC - Message.hpp Message.cpp - MessageSerializer.hpp MessageSerializer.cpp - PluginBlocks.hpp - LLMRequest.hpp - ResponseEvent.hpp - ConversationHistory.hpp ConversationHistory.cpp - ResponseRouter.hpp ResponseRouter.cpp - Session.hpp Session.cpp - SessionManager.hpp SessionManager.cpp - SystemPromptBuilder.hpp SystemPromptBuilder.cpp -) - -target_link_libraries(Session - PUBLIC - Qt::Core - LLMQore - Agents -) - -target_include_directories(Session PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR} -) diff --git a/sources/Session/ConversationHistory.cpp b/sources/Session/ConversationHistory.cpp deleted file mode 100644 index c74c26b..0000000 --- a/sources/Session/ConversationHistory.cpp +++ /dev/null @@ -1,83 +0,0 @@ -// 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 "ConversationHistory.hpp" - -namespace QodeAssist { - -ConversationHistory::ConversationHistory(QObject *parent) - : QObject(parent) -{} - -ConversationHistory::~ConversationHistory() = default; - -void ConversationHistory::append(Message message) -{ - m_messages.push_back(std::move(message)); - emit messageAdded(static_cast(m_messages.size()) - 1); -} - -void ConversationHistory::appendBlockToLast(std::unique_ptr block) -{ - if (m_messages.empty() || !block) - return; - - m_messages.back().appendBlock(std::move(block)); - emit messageUpdated(static_cast(m_messages.size()) - 1); -} - -void ConversationHistory::appendTextDeltaToLast(const QString &delta) -{ - if (m_messages.empty() || delta.isEmpty()) - return; - - auto &last = m_messages.back(); - if (auto *text = last.lastBlockOfType()) { - text->appendText(delta); - } else { - last.appendBlock(std::make_unique(delta)); - } - emit messageUpdated(static_cast(m_messages.size()) - 1); -} - -void ConversationHistory::appendThinkingDeltaToLast(const QString &delta, const QString &signature) -{ - if (m_messages.empty() || (delta.isEmpty() && signature.isEmpty())) - return; - - auto &last = m_messages.back(); - auto *thinking = last.lastBlockOfType(); - if (!thinking) { - auto fresh = std::make_unique(delta, signature); - last.appendBlock(std::move(fresh)); - } else { - if (!delta.isEmpty()) - thinking->appendThinking(delta); - if (!signature.isEmpty()) - thinking->setSignature(signature); - } - emit messageUpdated(static_cast(m_messages.size()) - 1); -} - -void ConversationHistory::clear() -{ - if (m_messages.empty()) - return; - - m_messages.clear(); - emit cleared(); -} - -void ConversationHistory::resetTo(int index) -{ - if (index < 0) - index = 0; - if (static_cast(index) >= m_messages.size()) - return; - - m_messages.resize(static_cast(index)); - emit reset(); -} - -} // namespace QodeAssist diff --git a/sources/Session/ConversationHistory.hpp b/sources/Session/ConversationHistory.hpp deleted file mode 100644 index 0bb7979..0000000 --- a/sources/Session/ConversationHistory.hpp +++ /dev/null @@ -1,50 +0,0 @@ -// 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 -#include - -#include "Message.hpp" - -namespace QodeAssist { - -class ConversationHistory : public QObject -{ - Q_OBJECT - Q_DISABLE_COPY_MOVE(ConversationHistory) -public: - explicit ConversationHistory(QObject *parent = nullptr); - ~ConversationHistory() override; - - const std::vector &messages() const noexcept { return m_messages; } - int size() const noexcept { return static_cast(m_messages.size()); } - bool isEmpty() const noexcept { return m_messages.empty(); } - - void append(Message message); - - void appendBlockToLast(std::unique_ptr block); - - void appendTextDeltaToLast(const QString &delta); - void appendThinkingDeltaToLast(const QString &delta, const QString &signature = QString()); - - void clear(); - void resetTo(int index); - -signals: - void messageAdded(int index); - void messageUpdated(int index); - void cleared(); - void reset(); - -private: - std::vector m_messages; -}; - -} // namespace QodeAssist diff --git a/sources/Session/LLMRequest.hpp b/sources/Session/LLMRequest.hpp deleted file mode 100644 index cd80156..0000000 --- a/sources/Session/LLMRequest.hpp +++ /dev/null @@ -1,33 +0,0 @@ -// 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 - -#include "Message.hpp" - -namespace QodeAssist { - -struct LLMRequest -{ - QString systemPrompt; - std::vector history; - bool toolsEnabled = false; - bool thinkingEnabled = false; - - std::optional fimPrefix; - std::optional fimSuffix; - - LLMRequest() = default; - LLMRequest(const LLMRequest &) = delete; - LLMRequest &operator=(const LLMRequest &) = delete; - LLMRequest(LLMRequest &&) noexcept = default; - LLMRequest &operator=(LLMRequest &&) noexcept = default; -}; - -} // namespace QodeAssist diff --git a/sources/Session/Message.cpp b/sources/Session/Message.cpp deleted file mode 100644 index cc40421..0000000 --- a/sources/Session/Message.cpp +++ /dev/null @@ -1,31 +0,0 @@ -// 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 "Message.hpp" - -namespace QodeAssist { - -QString Message::text() const -{ - QString out; - for (const auto &block : m_blocks) { - if (auto *t = dynamic_cast(block.get())) { - if (!out.isEmpty()) - out += QStringLiteral("\n\n"); - out += t->text(); - } - } - return out; -} - -bool Message::hasToolUse() const -{ - for (const auto &block : m_blocks) { - if (dynamic_cast(block.get())) - return true; - } - return false; -} - -} // namespace QodeAssist diff --git a/sources/Session/Message.hpp b/sources/Session/Message.hpp deleted file mode 100644 index 5b5e07c..0000000 --- a/sources/Session/Message.hpp +++ /dev/null @@ -1,74 +0,0 @@ -// 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 -#include - -namespace QodeAssist { - -class Message -{ -public: - enum class Role { System, User, Assistant }; - - Message() = default; - explicit Message(Role role, QString id = QString()) - : m_role(role) - , m_id(std::move(id)) - {} - - Message(const Message &) = delete; - Message &operator=(const Message &) = delete; - Message(Message &&) noexcept = default; - Message &operator=(Message &&) noexcept = default; - ~Message() = default; - - Role role() const noexcept { return m_role; } - const QString &id() const noexcept { return m_id; } - void setId(QString id) { m_id = std::move(id); } - - const std::vector> &blocks() const noexcept - { - return m_blocks; - } - - void appendBlock(std::unique_ptr block) - { - if (block) - m_blocks.push_back(std::move(block)); - } - - template - T *lastBlockOfType() - { - for (auto it = m_blocks.rbegin(); it != m_blocks.rend(); ++it) { - if (auto *p = dynamic_cast(it->get())) - return p; - } - return nullptr; - } - - template - const T *lastBlockOfType() const - { - return const_cast(this)->lastBlockOfType(); - } - - QString text() const; - - bool hasToolUse() const; - -private: - Role m_role = Role::User; - QString m_id; - std::vector> m_blocks; -}; - -} // namespace QodeAssist diff --git a/sources/Session/MessageSerializer.cpp b/sources/Session/MessageSerializer.cpp deleted file mode 100644 index 55582df..0000000 --- a/sources/Session/MessageSerializer.cpp +++ /dev/null @@ -1,213 +0,0 @@ -// 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 "MessageSerializer.hpp" - -#include "PluginBlocks.hpp" - -#include - -#include -#include - -#include - -namespace QodeAssist { - -namespace { - -constexpr auto kKindText = "text"; -constexpr auto kKindThinking = "thinking"; -constexpr auto kKindRedactedThinking = "redacted_thinking"; -constexpr auto kKindImage = "image"; -constexpr auto kKindToolUse = "tool_use"; -constexpr auto kKindToolResult = "tool_result"; -constexpr auto kKindStoredImage = "stored_image"; -constexpr auto kKindStoredAttachment = "stored_attachment"; -constexpr auto kKindFileEdit = "file_edit"; - -QString roleToString(Message::Role role) -{ - switch (role) { - case Message::Role::System: return QStringLiteral("system"); - case Message::Role::User: return QStringLiteral("user"); - case Message::Role::Assistant: return QStringLiteral("assistant"); - } - return QStringLiteral("user"); -} - -bool roleFromString(const QString &str, Message::Role *out) -{ - if (str == QLatin1String("system")) { - *out = Message::Role::System; - return true; - } - if (str == QLatin1String("user")) { - *out = Message::Role::User; - return true; - } - if (str == QLatin1String("assistant")) { - *out = Message::Role::Assistant; - return true; - } - return false; -} - -QJsonObject blockToJson(const LLMQore::ContentBlock &block) -{ - QJsonObject obj; - if (auto *t = dynamic_cast(&block)) { - obj["type"] = kKindText; - obj["text"] = t->text(); - } else if (auto *th = dynamic_cast(&block)) { - obj["type"] = kKindThinking; - obj["thinking"] = th->thinking(); - obj["signature"] = th->signature(); - } else if (auto *rth = dynamic_cast(&block)) { - obj["type"] = kKindRedactedThinking; - obj["signature"] = rth->signature(); - } else if (auto *img = dynamic_cast(&block)) { - obj["type"] = kKindImage; - obj["data"] = img->data(); - obj["mediaType"] = img->mediaType(); - obj["sourceType"] = (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url) - ? QStringLiteral("url") - : QStringLiteral("base64"); - } else if (auto *tu = dynamic_cast(&block)) { - obj["type"] = kKindToolUse; - obj["id"] = tu->id(); - obj["name"] = tu->name(); - obj["input"] = tu->input(); - } else if (auto *tr = dynamic_cast(&block)) { - obj["type"] = kKindToolResult; - obj["toolUseId"] = tr->toolUseId(); - obj["result"] = tr->result(); - } else if (auto *si = dynamic_cast(&block)) { - obj["type"] = kKindStoredImage; - obj["fileName"] = si->fileName(); - obj["storedPath"] = si->storedPath(); - obj["mediaType"] = si->mediaType(); - } else if (auto *sa = dynamic_cast(&block)) { - obj["type"] = kKindStoredAttachment; - obj["fileName"] = sa->fileName(); - obj["storedPath"] = sa->storedPath(); - } else if (auto *fe = dynamic_cast(&block)) { - obj["type"] = kKindFileEdit; - obj["editId"] = fe->editId(); - obj["filePath"] = fe->filePath(); - obj["oldContent"] = fe->oldContent(); - obj["newContent"] = fe->newContent(); - obj["status"] = FileEditContent::statusToString(fe->status()); - if (!fe->statusMessage().isEmpty()) - obj["statusMessage"] = fe->statusMessage(); - } - return obj; -} - -std::unique_ptr blockFromJson(const QJsonObject &obj) -{ - const QString type = obj.value("type").toString(); - if (type == kKindText) { - return std::make_unique(obj.value("text").toString()); - } - if (type == kKindThinking) { - return std::make_unique( - obj.value("thinking").toString(), obj.value("signature").toString()); - } - if (type == kKindRedactedThinking) { - return std::make_unique( - obj.value("signature").toString()); - } - if (type == kKindImage) { - const auto sourceType - = (obj.value("sourceType").toString() == QLatin1String("url")) - ? LLMQore::ImageContent::ImageSourceType::Url - : LLMQore::ImageContent::ImageSourceType::Base64; - return std::make_unique( - obj.value("data").toString(), obj.value("mediaType").toString(), sourceType); - } - if (type == kKindToolUse) { - return std::make_unique( - obj.value("id").toString(), - obj.value("name").toString(), - obj.value("input").toObject()); - } - if (type == kKindToolResult) { - return std::make_unique( - obj.value("toolUseId").toString(), obj.value("result").toString()); - } - if (type == kKindStoredImage) { - return std::make_unique( - obj.value("fileName").toString(), - obj.value("storedPath").toString(), - obj.value("mediaType").toString()); - } - if (type == kKindStoredAttachment) { - return std::make_unique( - obj.value("fileName").toString(), obj.value("storedPath").toString()); - } - if (type == kKindFileEdit) { - return std::make_unique( - obj.value("editId").toString(), - obj.value("filePath").toString(), - obj.value("oldContent").toString(), - obj.value("newContent").toString(), - FileEditContent::statusFromString(obj.value("status").toString()), - obj.value("statusMessage").toString()); - } - return nullptr; // unknown type — skipped -} - -} // namespace - -QJsonObject MessageSerializer::toJson(const Message &message) -{ - QJsonObject obj; - obj["role"] = roleToString(message.role()); - if (!message.id().isEmpty()) - obj["id"] = message.id(); - - QJsonArray blocks; - for (const auto &b : message.blocks()) { - if (b) - blocks.append(blockToJson(*b)); - } - obj["blocks"] = blocks; - return obj; -} - -Message MessageSerializer::fromJson(const QJsonObject &json, bool *ok) -{ - Message::Role role; - if (!roleFromString(json.value("role").toString(), &role)) { - if (ok) - *ok = false; - return Message(); - } - - Message m(role, json.value("id").toString()); - const QJsonArray blocks = json.value("blocks").toArray(); - int unknownBlocks = 0; - for (const QJsonValue &v : blocks) { - const QJsonObject blockObj = v.toObject(); - auto block = blockFromJson(blockObj); - if (block) { - m.appendBlock(std::move(block)); - } else { - ++unknownBlocks; - qWarning("[QodeAssist] MessageSerializer: unknown block type '%s' " - "in stored chat — skipped", - qUtf8Printable(blockObj.value("type").toString())); - } - } - - if (ok) { - *ok = m.blocks().size() > 0 || blocks.isEmpty(); - if (unknownBlocks > 0 && !blocks.isEmpty() && m.blocks().empty()) - *ok = false; - } - return m; -} - -} // namespace QodeAssist diff --git a/sources/Session/MessageSerializer.hpp b/sources/Session/MessageSerializer.hpp deleted file mode 100644 index 14332df..0000000 --- a/sources/Session/MessageSerializer.hpp +++ /dev/null @@ -1,21 +0,0 @@ -// 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 "Message.hpp" - -namespace QodeAssist { - -class MessageSerializer -{ -public: - static QJsonObject toJson(const Message &message); - - static Message fromJson(const QJsonObject &json, bool *ok = nullptr); -}; - -} // namespace QodeAssist diff --git a/sources/Session/PluginBlocks.hpp b/sources/Session/PluginBlocks.hpp deleted file mode 100644 index ad03950..0000000 --- a/sources/Session/PluginBlocks.hpp +++ /dev/null @@ -1,115 +0,0 @@ -// 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 - -namespace QodeAssist { - -class StoredImageContent : public LLMQore::ContentBlock -{ -public: - StoredImageContent(QString fileName, QString storedPath, QString mediaType) - : m_fileName(std::move(fileName)) - , m_storedPath(std::move(storedPath)) - , m_mediaType(std::move(mediaType)) - {} - - QString type() const override { return QStringLiteral("stored_image"); } - - QString fileName() const { return m_fileName; } - QString storedPath() const { return m_storedPath; } - QString mediaType() const { return m_mediaType; } - -private: - QString m_fileName; - QString m_storedPath; - QString m_mediaType; -}; - -class StoredAttachmentContent : public LLMQore::ContentBlock -{ -public: - StoredAttachmentContent(QString fileName, QString storedPath) - : m_fileName(std::move(fileName)) - , m_storedPath(std::move(storedPath)) - {} - - QString type() const override { return QStringLiteral("stored_attachment"); } - - QString fileName() const { return m_fileName; } - QString storedPath() const { return m_storedPath; } - -private: - QString m_fileName; - QString m_storedPath; -}; - -class FileEditContent : public LLMQore::ContentBlock -{ -public: - enum class Status { Pending, Applied, Rejected, Archived }; - - FileEditContent( - QString editId, - QString filePath, - QString oldContent, - QString newContent, - Status status = Status::Pending, - QString statusMessage = QString()) - : m_editId(std::move(editId)) - , m_filePath(std::move(filePath)) - , m_oldContent(std::move(oldContent)) - , m_newContent(std::move(newContent)) - , m_status(status) - , m_statusMessage(std::move(statusMessage)) - {} - - QString type() const override { return QStringLiteral("file_edit"); } - - QString editId() const { return m_editId; } - QString filePath() const { return m_filePath; } - QString oldContent() const { return m_oldContent; } - QString newContent() const { return m_newContent; } - Status status() const { return m_status; } - QString statusMessage() const { return m_statusMessage; } - - void setStatus(Status status) { m_status = status; } - void setStatusMessage(QString msg) { m_statusMessage = std::move(msg); } - - static QString statusToString(Status s) - { - switch (s) { - case Status::Pending: return QStringLiteral("pending"); - case Status::Applied: return QStringLiteral("applied"); - case Status::Rejected: return QStringLiteral("rejected"); - case Status::Archived: return QStringLiteral("archived"); - } - return QStringLiteral("pending"); - } - - static Status statusFromString(const QString &s) - { - if (s == QLatin1String("applied")) - return Status::Applied; - if (s == QLatin1String("rejected")) - return Status::Rejected; - if (s == QLatin1String("archived")) - return Status::Archived; - return Status::Pending; - } - -private: - QString m_editId; - QString m_filePath; - QString m_oldContent; - QString m_newContent; - Status m_status; - QString m_statusMessage; -}; - -} // namespace QodeAssist diff --git a/sources/Session/ResponseEvent.hpp b/sources/Session/ResponseEvent.hpp deleted file mode 100644 index cca9951..0000000 --- a/sources/Session/ResponseEvent.hpp +++ /dev/null @@ -1,170 +0,0 @@ -// 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 { - -namespace ResponseEvents { - -struct TextDelta -{ - QString text; -}; - -struct ThinkingDelta -{ - QString thinking; - QString signature; -}; - -struct ToolCallStart -{ - QString id; - QString name; -}; - -struct ToolCallArgsDelta -{ - QString id; - QString jsonFragment; -}; - -struct ToolCallEnd -{ - QString id; - QJsonObject finalArgs; -}; - -struct ToolResult -{ - QString toolUseId; - QString text; - bool isError = false; -}; - -struct Usage -{ - int inputTokens = 0; - int outputTokens = 0; -}; - -struct Error -{ - QString message; -}; - -struct MessageStop -{ - QString stopReason; -}; - -} // namespace ResponseEvents - -class ResponseEvent -{ -public: - enum class Kind { - MessageStart, - TextDelta, - ThinkingDelta, - ToolCallStart, - ToolCallArgsDelta, - ToolCallEnd, - ToolResult, - Usage, - MessageStop, - Error, - }; - - Kind kind() const noexcept { return m_kind; } - - template - const T *as() const noexcept - { - return std::get_if(&m_data); - } - - static ResponseEvent messageStart() { return {Kind::MessageStart, std::monostate{}}; } - - static ResponseEvent messageStop(QString stopReason = QString()) - { - return {Kind::MessageStop, ResponseEvents::MessageStop{std::move(stopReason)}}; - } - - static ResponseEvent textDelta(QString text) - { - return {Kind::TextDelta, ResponseEvents::TextDelta{std::move(text)}}; - } - - static ResponseEvent thinkingDelta(QString thinking, QString signature = QString()) - { - return { - Kind::ThinkingDelta, - ResponseEvents::ThinkingDelta{std::move(thinking), std::move(signature)}}; - } - - static ResponseEvent toolCallStart(QString id, QString name) - { - return {Kind::ToolCallStart, ResponseEvents::ToolCallStart{std::move(id), std::move(name)}}; - } - - static ResponseEvent toolCallArgsDelta(QString id, QString jsonFragment) - { - return { - Kind::ToolCallArgsDelta, - ResponseEvents::ToolCallArgsDelta{std::move(id), std::move(jsonFragment)}}; - } - - static ResponseEvent toolCallEnd(QString id, QJsonObject finalArgs) - { - return { - Kind::ToolCallEnd, ResponseEvents::ToolCallEnd{std::move(id), std::move(finalArgs)}}; - } - - static ResponseEvent toolResult(QString toolUseId, QString text, bool isError = false) - { - return { - Kind::ToolResult, - ResponseEvents::ToolResult{std::move(toolUseId), std::move(text), isError}}; - } - - static ResponseEvent usage(int inputTokens, int outputTokens) - { - return {Kind::Usage, ResponseEvents::Usage{inputTokens, outputTokens}}; - } - - static ResponseEvent error(QString message) - { - return {Kind::Error, ResponseEvents::Error{std::move(message)}}; - } - -private: - using Data = std::variant< - std::monostate, - ResponseEvents::TextDelta, - ResponseEvents::ThinkingDelta, - ResponseEvents::ToolCallStart, - ResponseEvents::ToolCallArgsDelta, - ResponseEvents::ToolCallEnd, - ResponseEvents::ToolResult, - ResponseEvents::Usage, - ResponseEvents::Error, - ResponseEvents::MessageStop>; - - ResponseEvent(Kind kind, Data data) - : m_kind(kind) - , m_data(std::move(data)) - {} - - Kind m_kind; - Data m_data; -}; - -} // namespace QodeAssist diff --git a/sources/Session/ResponseRouter.cpp b/sources/Session/ResponseRouter.cpp deleted file mode 100644 index a7639a1..0000000 --- a/sources/Session/ResponseRouter.cpp +++ /dev/null @@ -1,164 +0,0 @@ -// 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 "ResponseRouter.hpp" - -#include - -#include - -#include "ConversationHistory.hpp" -#include "Message.hpp" - -namespace QodeAssist { - -ResponseRouter::ResponseRouter( - LLMQore::BaseClient *client, ConversationHistory *history, QObject *parent) - : QObject(parent) - , m_client(client) - , m_history(history) -{ - if (!m_client) - return; - - connect( - m_client.data(), - &LLMQore::BaseClient::chunkReceived, - this, - &ResponseRouter::onChunk); - connect( - m_client.data(), - &LLMQore::BaseClient::thinkingBlockReceived, - this, - &ResponseRouter::onThinking); - connect( - m_client.data(), - &LLMQore::BaseClient::toolStarted, - this, - &ResponseRouter::onToolStarted); - connect( - m_client.data(), - &LLMQore::BaseClient::toolResultReady, - this, - &ResponseRouter::onToolResultReady); - connect( - m_client.data(), - &LLMQore::BaseClient::requestFinalized, - this, - &ResponseRouter::onFinalized); - connect( - m_client.data(), - &LLMQore::BaseClient::requestFailed, - this, - &ResponseRouter::onFailed); -} - -ResponseRouter::~ResponseRouter() = default; - -void ResponseRouter::beginRequest(const LLMQore::RequestID &id) -{ - m_activeId = id; - resetTurnState(); -} - -void ResponseRouter::endRequest() -{ - m_activeId.clear(); - resetTurnState(); -} - -void ResponseRouter::resetTurnState() -{ - m_assistantOpen = false; - m_inToolResults = false; -} - -void ResponseRouter::ensureAssistantOpen() -{ - if (m_assistantOpen && !m_inToolResults) - return; - if (m_history) - m_history->append(Message(Message::Role::Assistant)); - emit event(ResponseEvent::messageStart()); - m_assistantOpen = true; - m_inToolResults = false; -} - -void ResponseRouter::onChunk(const LLMQore::RequestID &id, const QString &chunk) -{ - if (id != m_activeId || chunk.isEmpty()) - return; - ensureAssistantOpen(); - if (m_history) - m_history->appendTextDeltaToLast(chunk); - emit event(ResponseEvent::textDelta(chunk)); -} - -void ResponseRouter::onThinking( - const LLMQore::RequestID &id, const QString &thinking, const QString &signature) -{ - if (id != m_activeId || (thinking.isEmpty() && signature.isEmpty())) - return; - ensureAssistantOpen(); - if (m_history) - m_history->appendThinkingDeltaToLast(thinking, signature); - emit event(ResponseEvent::thinkingDelta(thinking, signature)); -} - -void ResponseRouter::onToolStarted( - const LLMQore::RequestID &id, const QString &toolId, const QString &toolName) -{ - if (id != m_activeId) - return; - ensureAssistantOpen(); - if (m_history) - m_history->appendBlockToLast( - std::make_unique(toolId, toolName)); - emit event(ResponseEvent::toolCallStart(toolId, toolName)); -} - -void ResponseRouter::onToolResultReady( - const LLMQore::RequestID &id, - const QString &toolId, - const QString &toolName, - const QString &result) -{ - Q_UNUSED(toolName); - if (id != m_activeId) - return; - - if (m_history) { - if (m_inToolResults) { - m_history->appendBlockToLast( - std::make_unique(toolId, result)); - } else { - Message m(Message::Role::User); - m.appendBlock(std::make_unique(toolId, result)); - m_history->append(std::move(m)); - } - } - - m_assistantOpen = false; - m_inToolResults = true; - emit event(ResponseEvent::toolResult(toolId, result, /*isError=*/false)); -} - -void ResponseRouter::onFinalized( - const LLMQore::RequestID &id, const LLMQore::CompletionInfo &info) -{ - if (id != m_activeId) - return; - emit event(ResponseEvent::messageStop(info.stopReason)); - endRequest(); -} - -void ResponseRouter::onFailed(const LLMQore::RequestID &id, const QString &err) -{ - if (id != m_activeId) - return; - emit event(ResponseEvent::error(err)); - endRequest(); -} - -} // namespace QodeAssist diff --git a/sources/Session/ResponseRouter.hpp b/sources/Session/ResponseRouter.hpp deleted file mode 100644 index 1512ace..0000000 --- a/sources/Session/ResponseRouter.hpp +++ /dev/null @@ -1,65 +0,0 @@ -// 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 -#include - -#include "ResponseEvent.hpp" - -namespace QodeAssist { - -class ConversationHistory; - -class ResponseRouter : public QObject -{ - Q_OBJECT - Q_DISABLE_COPY_MOVE(ResponseRouter) -public: - ResponseRouter( - LLMQore::BaseClient *client, - ConversationHistory *history, - QObject *parent = nullptr); - ~ResponseRouter() override; - - void beginRequest(const LLMQore::RequestID &id); - void endRequest(); - - bool isActive() const noexcept { return !m_activeId.isEmpty(); } - LLMQore::RequestID activeRequestId() const noexcept { return m_activeId; } - -signals: - void event(const QodeAssist::ResponseEvent &ev); - -private slots: - void onChunk(const LLMQore::RequestID &id, const QString &chunk); - void onThinking( - const LLMQore::RequestID &id, const QString &thinking, const QString &signature); - void onToolStarted( - const LLMQore::RequestID &id, const QString &toolId, const QString &toolName); - void onToolResultReady( - const LLMQore::RequestID &id, - const QString &toolId, - const QString &toolName, - const QString &result); - void onFinalized(const LLMQore::RequestID &id, const LLMQore::CompletionInfo &info); - void onFailed(const LLMQore::RequestID &id, const QString &err); - -private: - void ensureAssistantOpen(); - void resetTurnState(); - - QPointer m_client; - QPointer m_history; - - LLMQore::RequestID m_activeId; - bool m_assistantOpen = false; - bool m_inToolResults = false; -}; - -} // namespace QodeAssist diff --git a/sources/Session/Session.cpp b/sources/Session/Session.cpp deleted file mode 100644 index 83bf52d..0000000 --- a/sources/Session/Session.cpp +++ /dev/null @@ -1,387 +0,0 @@ -// 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 "Session.hpp" - -#include - -#include -#include -#include - -#include - -#include "Agent.hpp" -#include "AgentConfig.hpp" -#include "ContextData.hpp" -#include "Message.hpp" -#include "PluginBlocks.hpp" -#include "PromptTemplate.hpp" -#include "Provider.hpp" -#include "ResponseRouter.hpp" -#include "SystemPromptBuilder.hpp" - -namespace QodeAssist { - -namespace { - -QString roleToLegacyString(Message::Role role) -{ - switch (role) { - case Message::Role::System: return QStringLiteral("system"); - case Message::Role::User: return QStringLiteral("user"); - case Message::Role::Assistant: return QStringLiteral("assistant"); - } - return QStringLiteral("user"); -} - -} // namespace - -Session::Session(QObject *parent) - : QObject(parent) - , m_history(new ConversationHistory(this)) - , m_systemPrompt(new SystemPromptBuilder(this)) -{ - m_invalidReason = QStringLiteral("Session: no agent attached"); -} - -Session::Session(Agent *agent, QObject *parent) - : Session(agent, /*externalHistory=*/nullptr, parent) -{} - -Session::Session(Agent *agent, ConversationHistory *externalHistory, QObject *parent) - : QObject(parent) - , m_agent(agent) - , m_history(externalHistory ? externalHistory : new ConversationHistory(this)) - , m_systemPrompt(new SystemPromptBuilder(this)) -{ - if (!m_agent) { - m_invalidReason = QStringLiteral("Session: agent is null"); - return; - } - m_agent->setParent(this); - - if (!m_agent->isValid()) { - m_invalidReason = m_agent->invalidReason(); - return; - } - - auto *provider = m_agent->provider(); - auto *client = provider ? provider->client() : nullptr; - if (!client) { - m_invalidReason = QStringLiteral("Session: provider has no live client"); - return; - } - if (!m_agent->promptTemplate()) { - m_invalidReason - = QStringLiteral("Session: agent has no inline prompt template"); - return; - } - - m_router = new ResponseRouter(client, m_history, this); - connect(m_router, &ResponseRouter::event, this, &Session::onRouterEvent); - - m_systemPrompt->setLayer(QStringLiteral("agent.role"), m_agent->config().role); -} - -Session::~Session() -{ - if (isInFlight()) - cancel(); -} - -bool Session::isValid() const noexcept -{ - return m_invalidReason.isEmpty(); -} - -QString Session::invalidReason() const -{ - return m_invalidReason; -} - -bool Session::isInFlight() const noexcept -{ - return !m_inFlight.isEmpty(); -} - -void Session::setContentLoader(ContentLoader loader) -{ - m_contentLoader = std::move(loader); -} - -void Session::setContextBindings(Templates::ContextRenderer::Bindings bindings) -{ - m_contextBindings = std::move(bindings); -} - -QString Session::renderAgentContext() const -{ - if (!m_agent) - return {}; - const auto &cfg = m_agent->config(); - if (cfg.context.isEmpty()) - return {}; - QString err; - QString rendered = Templates::ContextRenderer::render(cfg.context, m_contextBindings, &err); - if (!err.isEmpty()) - qWarning("[QodeAssist] agent.context render failed: %s", qUtf8Printable(err)); - return rendered; -} - -LLMQore::RequestID Session::sendText(const QString &text) -{ - std::vector> blocks; - if (!text.isEmpty()) - blocks.push_back(std::make_unique(text)); - return send(std::move(blocks)); -} - -LLMQore::RequestID Session::send( - std::vector> userBlocks, - std::optional toolsOverride) -{ - if (!isValid() || userBlocks.empty()) - return {}; - if (!m_history) - return {}; - - if (isInFlight()) - cancel(); - - Message msg(Message::Role::User); - for (auto &b : userBlocks) - msg.appendBlock(std::move(b)); - m_history->append(std::move(msg)); - - return dispatch(toolsOverride); -} - -void Session::cancel() -{ - if (m_inFlight.isEmpty()) - return; - - const auto id = m_inFlight; - m_inFlight.clear(); - if (m_router) - m_router->endRequest(); - if (m_agent && m_agent->provider()) - m_agent->provider()->cancelRequest(id); - emit failed(id, QStringLiteral("Cancelled by user")); -} - -LLMQore::RequestID Session::sendCompletion(Templates::ContextData ctx) -{ - if (!isValid()) - return {}; - if (isInFlight()) - cancel(); - - if (m_history) - m_history->clear(); - - auto *provider = m_agent->provider(); - auto *tmpl = m_agent->promptTemplate(); - const auto &cfg = m_agent->config(); - - QJsonObject payload{{QStringLiteral("model"), cfg.model}}; - if (!provider->prepareRequest(payload, tmpl, ctx, /*tools=*/false, /*thinking=*/false)) - return {}; - - const auto id = provider->sendRequest(QUrl(provider->url()), payload, cfg.endpoint); - if (id.isEmpty()) - return {}; - - m_inFlight = id; - if (m_router) - m_router->beginRequest(id); - emit started(id); - return id; -} - -LLMQore::RequestID Session::dispatch(std::optional toolsOverride) -{ - auto *provider = m_agent->provider(); - auto *tmpl = m_agent->promptTemplate(); - const auto &cfg = m_agent->config(); - - const QString renderedContext = renderAgentContext(); - if (renderedContext.isEmpty()) - m_systemPrompt->clearLayer(QStringLiteral("agent.context")); - else - m_systemPrompt->setLayer(QStringLiteral("agent.context"), renderedContext); - - Templates::ContextData ctx = toLegacyContext(); - QJsonObject payload{{QStringLiteral("model"), cfg.model}}; - - const bool tools = toolsOverride.value_or(cfg.enableTools); - if (!provider->prepareRequest(payload, tmpl, ctx, tools, cfg.enableThinking)) - return {}; - - const auto id = provider->sendRequest(QUrl(provider->url()), payload, cfg.endpoint); - if (id.isEmpty()) - return {}; - - m_inFlight = id; - if (m_router) - m_router->beginRequest(id); - emit started(id); - return id; -} - -Templates::ContextData Session::toLegacyContext() const -{ - if (!m_history) - return {}; - return buildLegacyContext(m_history->messages(), m_systemPrompt->compose(), m_contentLoader); -} - -Templates::ContextData Session::buildLegacyContext( - const std::vector &history, - const QString &systemPrompt, - const ContentLoader &loader) -{ - using Templates::ContentBlockEntry; - using Templates::ContextData; - using LegacyMessage = Templates::Message; - - ContextData ctx; - if (!systemPrompt.isEmpty()) - ctx.systemPrompt = systemPrompt; - - QSet resolvedToolUseIds; - QSet declaredToolUseIds; - for (const auto &m : history) { - for (const auto &blockPtr : m.blocks()) { - if (auto *tr = dynamic_cast(blockPtr.get())) - resolvedToolUseIds.insert(tr->toolUseId()); - if (auto *tu = dynamic_cast(blockPtr.get())) - declaredToolUseIds.insert(tu->id()); - } - } - - QVector hist; - - for (const auto &m : history) { - QVector blockEntries; - - for (const auto &blockPtr : m.blocks()) { - auto *block = blockPtr.get(); - if (!block) - continue; - - if (auto *t = dynamic_cast(block)) { - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Text; - e.text = t->text(); - blockEntries.append(std::move(e)); - } else if (auto *img = dynamic_cast(block)) { - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Image; - e.imageData = img->data(); - e.mediaType = img->mediaType(); - e.isImageUrl - = (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url); - blockEntries.append(std::move(e)); - } else if (auto *si = dynamic_cast(block)) { - if (!loader) - continue; - const QString base64 = loader(si->storedPath()); - if (base64.isEmpty()) - continue; - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Image; - e.imageData = base64; - e.mediaType = si->mediaType(); - e.isImageUrl = false; - blockEntries.append(std::move(e)); - } else if (auto *sa = dynamic_cast(block)) { - if (!loader) - continue; - const QString text = loader(sa->storedPath()); - if (text.isEmpty()) - continue; - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Text; - e.text = QStringLiteral("File: %1\n```\n%2\n```") - .arg(sa->fileName(), text); - blockEntries.append(std::move(e)); - } else if (auto *th = dynamic_cast(block)) { - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Thinking; - e.thinking = th->thinking(); - e.signature = th->signature(); - blockEntries.append(std::move(e)); - } else if (auto *rth = dynamic_cast(block)) { - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::RedactedThinking; - e.signature = rth->signature(); - blockEntries.append(std::move(e)); - } else if (auto *tu = dynamic_cast(block)) { - if (!resolvedToolUseIds.contains(tu->id())) - continue; - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::ToolUse; - e.toolUseId = tu->id(); - e.toolName = tu->name(); - e.toolInput = tu->input(); - blockEntries.append(std::move(e)); - } else if (auto *tr = dynamic_cast(block)) { - if (!declaredToolUseIds.contains(tr->toolUseId())) - continue; - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::ToolResult; - e.toolUseId = tr->toolUseId(); - e.result = tr->result(); - blockEntries.append(std::move(e)); - } - } - - if (blockEntries.isEmpty()) - continue; - - const bool hasNonThinking = std::any_of( - blockEntries.begin(), blockEntries.end(), [](const ContentBlockEntry &e) { - return e.kind != ContentBlockEntry::Kind::Thinking - && e.kind != ContentBlockEntry::Kind::RedactedThinking; - }); - if (!hasNonThinking) - continue; - - LegacyMessage lm; - lm.role = roleToLegacyString(m.role()); - lm.blocks = std::move(blockEntries); - hist.append(std::move(lm)); - } - - if (!hist.isEmpty()) - ctx.history = std::move(hist); - - return ctx; -} - -void Session::onRouterEvent(const ResponseEvent &ev) -{ - if (m_inFlight.isEmpty()) - return; // stale events after cancel - - emit event(ev); - - if (ev.kind() == ResponseEvent::Kind::MessageStop) { - const auto *stop = ev.as(); - const QString reason = stop ? stop->stopReason : QString(); - const auto id = m_inFlight; - m_inFlight.clear(); - emit finished(id, reason); - } else if (ev.kind() == ResponseEvent::Kind::Error) { - const auto *err = ev.as(); - const QString msg = err ? err->message : QStringLiteral("unknown error"); - const auto id = m_inFlight; - m_inFlight.clear(); - emit failed(id, msg); - } -} - -} // namespace QodeAssist diff --git a/sources/Session/Session.hpp b/sources/Session/Session.hpp deleted file mode 100644 index 7f2e49b..0000000 --- a/sources/Session/Session.hpp +++ /dev/null @@ -1,106 +0,0 @@ -// 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 -#include - -#include -#include -#include - -#include -#include -#include -#include - -#include "ConversationHistory.hpp" -#include "ResponseEvent.hpp" - -namespace QodeAssist { - -class Agent; -class ResponseRouter; -class SystemPromptBuilder; - -class Session : public QObject -{ - Q_OBJECT - Q_DISABLE_COPY_MOVE(Session) -public: - explicit Session(QObject *parent = nullptr); - - Session( - Agent *agent, - ConversationHistory *externalHistory = nullptr, - QObject *parent = nullptr); - - Session(Agent *agent, QObject *parent); - - ~Session() override; - - bool isValid() const noexcept; - QString invalidReason() const; - bool isInFlight() const noexcept; - - using ContentLoader = std::function; - void setContentLoader(ContentLoader loader); - - Agent *agent() noexcept { return m_agent; } - ConversationHistory *history() const noexcept { return m_history; } - SystemPromptBuilder *systemPrompt() const noexcept { return m_systemPrompt; } - - void setContextBindings(Templates::ContextRenderer::Bindings bindings); - - QString renderAgentContext() const; - - LLMQore::RequestID send( - std::vector> userBlocks, - std::optional toolsOverride = std::nullopt); - - LLMQore::RequestID sendText(const QString &text); - - LLMQore::RequestID sendCompletion(Templates::ContextData ctx); - - void cancel(); - -signals: - void event(const QodeAssist::ResponseEvent &ev); - - void started(const LLMQore::RequestID &id); - void finished(const LLMQore::RequestID &id, const QString &stopReason); - void failed(const LLMQore::RequestID &id, const QString &error); - -private slots: - void onRouterEvent(const QodeAssist::ResponseEvent &ev); - -private: - LLMQore::RequestID dispatch(std::optional toolsOverride = std::nullopt); - Templates::ContextData toLegacyContext() const; - - Agent *m_agent = nullptr; // child if non-null - QPointer m_history; // child if internal, external otherwise - SystemPromptBuilder *m_systemPrompt = nullptr; // child - ResponseRouter *m_router = nullptr; // child, only when valid - - LLMQore::RequestID m_inFlight; - QString m_invalidReason; - - Templates::ContextRenderer::Bindings m_contextBindings; - -public: - static Templates::ContextData buildLegacyContext( - const std::vector &history, - const QString &systemPrompt, - const ContentLoader &loader = ContentLoader{}); - -private: - ContentLoader m_contentLoader; -}; - -} // namespace QodeAssist diff --git a/sources/Session/SessionManager.cpp b/sources/Session/SessionManager.cpp deleted file mode 100644 index 5af412f..0000000 --- a/sources/Session/SessionManager.cpp +++ /dev/null @@ -1,106 +0,0 @@ -// 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 "SessionManager.hpp" - -#include "Agent.hpp" -#include "AgentFactory.hpp" -#include "Session.hpp" - -namespace QodeAssist { - -SessionManager::SessionManager(QObject *parent) - : QObject(parent) -{} - -SessionManager::SessionManager(AgentFactory *agentFactory, QObject *parent) - : QObject(parent) - , m_agentFactory(agentFactory) -{} - -SessionManager::~SessionManager() = default; - -Session *SessionManager::createSession() -{ - auto *session = new Session(this); - m_sessions.append(session); - emit sessionCreated(session); - return session; -} - -Session *SessionManager::createSession(const QString &agentName, QString *errorOut) -{ - return createSession(agentName, /*externalHistory=*/nullptr, errorOut); -} - -Session *SessionManager::createSession( - const QString &agentName, ConversationHistory *externalHistory, QString *errorOut) -{ - if (!m_agentFactory) { - if (errorOut) - *errorOut = QStringLiteral("SessionManager: no AgentFactory bound"); - return nullptr; - } - - QString agentErr; - Agent *agent = m_agentFactory->create(agentName, /*parent=*/nullptr, &agentErr); - if (!agent) { - if (errorOut) - *errorOut = agentErr.isEmpty() - ? QStringLiteral("SessionManager: agent '%1' not found").arg(agentName) - : agentErr; - return nullptr; - } - - auto *session = new Session(agent, externalHistory, this); - if (!session->isValid()) { - if (errorOut) - *errorOut = session->invalidReason(); - delete session; // also deletes the reparented agent - return nullptr; - } - - m_sessions.append(session); - emit sessionCreated(session); - return session; -} - -void SessionManager::removeSession(Session *session) -{ - if (!session) - return; - - const int idx = m_sessions.indexOf(session); - if (idx < 0) - return; - - if (session->isInFlight()) - session->cancel(); - - m_sessions.removeAt(idx); - emit sessionRemoved(session); - session->deleteLater(); -} - -QList SessionManager::sessions() const -{ - QList out; - out.reserve(m_sessions.size()); - for (const auto &p : m_sessions) { - if (p) - out.append(p.data()); - } - return out; -} - -void SessionManager::cancelAll() -{ - const auto snapshot = m_sessions; - for (const auto &p : snapshot) { - if (p && p->isInFlight()) - p->cancel(); - } -} - -} // namespace QodeAssist diff --git a/sources/Session/SessionManager.hpp b/sources/Session/SessionManager.hpp deleted file mode 100644 index c36b546..0000000 --- a/sources/Session/SessionManager.hpp +++ /dev/null @@ -1,53 +0,0 @@ -// 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 -#include - -namespace QodeAssist { - -class AgentFactory; -class ConversationHistory; -class Session; - -class SessionManager : public QObject -{ - Q_OBJECT - Q_DISABLE_COPY_MOVE(SessionManager) -public: - explicit SessionManager(QObject *parent = nullptr); - - SessionManager(AgentFactory *agentFactory, QObject *parent = nullptr); - - ~SessionManager() override; - - Session *createSession(); - - Session *createSession(const QString &agentName, QString *errorOut = nullptr); - - Session *createSession( - const QString &agentName, - ConversationHistory *externalHistory, - QString *errorOut = nullptr); - - void removeSession(Session *session); - - QList sessions() const; - - void cancelAll(); - -signals: - void sessionCreated(Session *session); - void sessionRemoved(Session *session); - -private: - QPointer m_agentFactory; - QList> m_sessions; -}; - -} // namespace QodeAssist diff --git a/sources/Session/SystemPromptBuilder.cpp b/sources/Session/SystemPromptBuilder.cpp deleted file mode 100644 index 1573695..0000000 --- a/sources/Session/SystemPromptBuilder.cpp +++ /dev/null @@ -1,72 +0,0 @@ -// 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 "SystemPromptBuilder.hpp" - -namespace QodeAssist { - -SystemPromptBuilder::SystemPromptBuilder(QObject *parent) - : QObject(parent) -{} - -void SystemPromptBuilder::setLayer(const QString &name, const QString &text) -{ - for (auto &pair : m_layers) { - if (pair.first == name) { - if (pair.second == text) return; - pair.second = text; - emit layersChanged(); - return; - } - } - m_layers.append({name, text}); - emit layersChanged(); -} - -void SystemPromptBuilder::clearLayer(const QString &name) -{ - for (auto it = m_layers.begin(); it != m_layers.end(); ++it) { - if (it->first == name) { - m_layers.erase(it); - emit layersChanged(); - return; - } - } -} - -void SystemPromptBuilder::clear() -{ - if (m_layers.isEmpty()) return; - m_layers.clear(); - emit layersChanged(); -} - -QString SystemPromptBuilder::layer(const QString &name) const -{ - for (const auto &pair : m_layers) { - if (pair.first == name) return pair.second; - } - return {}; -} - -QStringList SystemPromptBuilder::layerNames() const -{ - QStringList out; - out.reserve(m_layers.size()); - for (const auto &pair : m_layers) out.append(pair.first); - return out; -} - -QString SystemPromptBuilder::compose(const QString &separator) const -{ - QStringList parts; - parts.reserve(m_layers.size()); - for (const auto &pair : m_layers) { - if (!pair.second.isEmpty()) - parts.append(pair.second); - } - return parts.join(separator); -} - -} // namespace QodeAssist diff --git a/sources/Session/SystemPromptBuilder.hpp b/sources/Session/SystemPromptBuilder.hpp deleted file mode 100644 index 85c414a..0000000 --- a/sources/Session/SystemPromptBuilder.hpp +++ /dev/null @@ -1,37 +0,0 @@ -// 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 -#include - -namespace QodeAssist { - -class SystemPromptBuilder : public QObject -{ - Q_OBJECT -public: - explicit SystemPromptBuilder(QObject *parent = nullptr); - - void setLayer(const QString &name, const QString &text); - void clearLayer(const QString &name); - void clear(); - - QString layer(const QString &name) const; - QStringList layerNames() const; - bool isEmpty() const { return m_layers.isEmpty(); } - - QString compose(const QString &separator = QStringLiteral("\n\n")) const; - -signals: - void layersChanged(); - -private: - QVector> m_layers; -}; - -} // namespace QodeAssist diff --git a/sources/external/llmqore b/sources/external/llmqore index 48e6dfb..3f9c8f5 160000 --- a/sources/external/llmqore +++ b/sources/external/llmqore @@ -1 +1 @@ -Subproject commit 48e6dfb30db49162f5ebbdbedaa9049f5cfd077c +Subproject commit 3f9c8f52534b2fd2699b56d2c22dde03bf0b6924 diff --git a/sources/mcp/McpServerConnection.cpp b/sources/mcp/McpServerConnection.cpp index ad4c013..08574f5 100644 --- a/sources/mcp/McpServerConnection.cpp +++ b/sources/mcp/McpServerConnection.cpp @@ -5,12 +5,11 @@ #include "McpServerConnection.hpp" #include -#include #include #include -#include -#include #include +#include +#include #include #include @@ -22,6 +21,8 @@ #include #include +#include + #include #include "providers/Provider.hpp" #include @@ -142,7 +143,7 @@ void McpServerConnection::setProviders(const QList &provi } } -::LLMQore::Mcp::McpTransport *McpServerConnection::createTransport() +::LLMQore::Rpc::Transport *McpServerConnection::createTransport() { if (m_config.transport == McpTransportKind::Http) { if (!m_config.url.isValid()) { @@ -166,7 +167,7 @@ void McpServerConnection::setProviders(const QList &provi return nullptr; } - ::LLMQore::Mcp::StdioLaunchConfig cfg; + ::LLMQore::Rpc::StdioLaunchConfig cfg; cfg.arguments = m_config.args; cfg.workingDirectory = m_config.workingDirectory; @@ -211,7 +212,7 @@ void McpServerConnection::setProviders(const QList &provi env.insert(it.key(), it.value()); cfg.environment = env; - return new ::LLMQore::Mcp::McpStdioClientTransport(cfg, this); + return new ::LLMQore::Rpc::StdioClientTransport(std::move(cfg), this); } void McpServerConnection::connectToServer() diff --git a/sources/mcp/McpServerConnection.hpp b/sources/mcp/McpServerConnection.hpp index 6f12d55..0ab68b8 100644 --- a/sources/mcp/McpServerConnection.hpp +++ b/sources/mcp/McpServerConnection.hpp @@ -16,9 +16,12 @@ namespace LLMQore::Mcp { class McpClient; -class McpTransport; } // namespace LLMQore::Mcp +namespace LLMQore::Rpc { +class Transport; +} // namespace LLMQore::Rpc + namespace QodeAssist::Providers { class Provider; } @@ -77,14 +80,14 @@ private: void fetchAndRegisterTools(); void registerTools(const QList<::LLMQore::Mcp::McpClient *> & /*unused*/); void unregisterTools(); - ::LLMQore::Mcp::McpTransport *createTransport(); + ::LLMQore::Rpc::Transport *createTransport(); McpServerConfig m_config; McpConnectionState m_state = McpConnectionState::Disabled; QString m_statusText; QPointer<::LLMQore::Mcp::McpClient> m_client; - QPointer<::LLMQore::Mcp::McpTransport> m_transport; + QPointer<::LLMQore::Rpc::Transport> m_transport; QPointer m_listToolsWatchdog; QList> m_providers; diff --git a/sources/plugin/qodeassist.cpp b/sources/plugin/qodeassist.cpp index f8c2200..b1fc3dd 100644 --- a/sources/plugin/qodeassist.cpp +++ b/sources/plugin/qodeassist.cpp @@ -73,6 +73,10 @@ #include #include +#ifdef WITH_TESTS +#include "QodeAssistTest.hpp" +#endif + using namespace Utils; using namespace Core; using namespace ProjectExplorer; @@ -305,6 +309,10 @@ public: } Chat::ChatFileManager::cleanupGlobalIntermediateStorage(); + +#ifdef WITH_TESTS + addTest(); +#endif } void extensionsInitialized() final {} diff --git a/sources/session/CMakeLists.txt b/sources/session/CMakeLists.txt new file mode 100644 index 0000000..f9f3aa4 --- /dev/null +++ b/sources/session/CMakeLists.txt @@ -0,0 +1,22 @@ +add_library(QodeAssistSession STATIC + ContentBlock.hpp + Message.hpp + ConversationHistory.hpp ConversationHistory.cpp + FileEditPayload.hpp FileEditPayload.cpp + HistoryProjection.hpp HistoryProjection.cpp + HistorySerializer.hpp HistorySerializer.cpp + SessionEvent.hpp SessionEvent.cpp + TurnRequest.hpp + ChatBackend.hpp + Session.hpp Session.cpp + TurnContext.hpp TurnContext.cpp + TurnContextPorts.hpp + TurnContextBuilder.hpp TurnContextBuilder.cpp +) + +target_link_libraries(QodeAssistSession + PUBLIC + Qt::Core +) + +target_include_directories(QodeAssistSession PUBLIC ${CMAKE_SOURCE_DIR}/sources) diff --git a/sources/session/ChatBackend.hpp b/sources/session/ChatBackend.hpp new file mode 100644 index 0000000..7b7d93e --- /dev/null +++ b/sources/session/ChatBackend.hpp @@ -0,0 +1,28 @@ +// 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 + +#include "session/SessionEvent.hpp" +#include "session/TurnRequest.hpp" + +namespace QodeAssist::Session { + +class ChatBackend : public QObject +{ + Q_OBJECT + +public: + using QObject::QObject; + + virtual void sendTurn(const TurnRequest &request) = 0; + virtual void cancel() = 0; + +signals: + void sessionEvent(const QodeAssist::Session::SessionEvent &event); +}; + +} // namespace QodeAssist::Session diff --git a/sources/session/ContentBlock.hpp b/sources/session/ContentBlock.hpp new file mode 100644 index 0000000..baed900 --- /dev/null +++ b/sources/session/ContentBlock.hpp @@ -0,0 +1,110 @@ +// 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 +#include +#include + +#include + +namespace QodeAssist::Session { + +struct TextBlock +{ + QString text; + + bool operator==(const TextBlock &other) const = default; + + friend QDebug operator<<(QDebug debug, const TextBlock &block) + { + return debug.nospace() << "Text(" << block.text << ")"; + } +}; + +struct ThinkingBlock +{ + QString text; + QString signature; + bool redacted = false; + + bool operator==(const ThinkingBlock &other) const = default; + + friend QDebug operator<<(QDebug debug, const ThinkingBlock &block) + { + return debug.nospace() << "Thinking(" << block.text << ", sig=" << block.signature + << ", redacted=" << block.redacted << ")"; + } +}; + +struct ToolCallBlock +{ + QString id; + QString name; + QJsonObject arguments; + QString result; + + bool operator==(const ToolCallBlock &other) const = default; + + friend QDebug operator<<(QDebug debug, const ToolCallBlock &block) + { + return debug.nospace() << "ToolCall(" << block.id << ", " << block.name + << ", args=" << block.arguments << ", result=" << block.result + << ")"; + } +}; + +struct AttachmentBlock +{ + QString fileName; + QString storedPath; + + bool operator==(const AttachmentBlock &other) const = default; + + friend QDebug operator<<(QDebug debug, const AttachmentBlock &block) + { + return debug.nospace() << "Attachment(" << block.fileName << ", " << block.storedPath + << ")"; + } +}; + +struct ImageBlock +{ + QString fileName; + QString storedPath; + QString mediaType; + + bool operator==(const ImageBlock &other) const = default; + + friend QDebug operator<<(QDebug debug, const ImageBlock &block) + { + return debug.nospace() << "Image(" << block.fileName << ", " << block.storedPath << ", " + << block.mediaType << ")"; + } +}; + +struct FileEditBlock +{ + QString id; + QString payload; + + bool operator==(const FileEditBlock &other) const = default; + + friend QDebug operator<<(QDebug debug, const FileEditBlock &block) + { + return debug.nospace() << "FileEdit(" << block.id << ", " << block.payload << ")"; + } +}; + +using ContentBlock = std:: + variant; + +inline QDebug operator<<(QDebug debug, const ContentBlock &block) +{ + std::visit([&debug](const auto &alternative) { debug << alternative; }, block); + return debug; +} + +} // namespace QodeAssist::Session diff --git a/sources/session/ConversationHistory.cpp b/sources/session/ConversationHistory.cpp new file mode 100644 index 0000000..4b94874 --- /dev/null +++ b/sources/session/ConversationHistory.cpp @@ -0,0 +1,47 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/ConversationHistory.hpp" + +namespace QodeAssist::Session { + +void ConversationHistory::append(const Message &message) +{ + m_messages.append(message); +} + +qsizetype ConversationHistory::size() const +{ + return m_messages.size(); +} + +const QList &ConversationHistory::messages() const +{ + return m_messages; +} + +const Message &ConversationHistory::at(qsizetype index) const +{ + return m_messages.at(index); +} + +const Message &ConversationHistory::last() const +{ + return m_messages.last(); +} + +Message *ConversationHistory::lastMessage() +{ + return m_messages.isEmpty() ? nullptr : &m_messages.last(); +} + +void ConversationHistory::visitBlocks(const std::function &visit) +{ + for (Message &message : m_messages) { + for (ContentBlock &block : message.blocks) + visit(block); + } +} + +} // namespace QodeAssist::Session diff --git a/sources/session/ConversationHistory.hpp b/sources/session/ConversationHistory.hpp new file mode 100644 index 0000000..6515076 --- /dev/null +++ b/sources/session/ConversationHistory.hpp @@ -0,0 +1,39 @@ +// 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 + +#include + +#include "session/Message.hpp" + +namespace QodeAssist::Session { + +class ConversationHistory +{ +public: + void append(const Message &message); + + qsizetype size() const; + const QList &messages() const; + const Message &at(qsizetype index) const; + const Message &last() const; + Message *lastMessage(); + + void visitBlocks(const std::function &visit); + + bool operator==(const ConversationHistory &other) const = default; + + friend QDebug operator<<(QDebug debug, const ConversationHistory &history) + { + return debug.nospace() << "History(" << history.m_messages << ")"; + } + +private: + QList m_messages; +}; + +} // namespace QodeAssist::Session diff --git a/sources/session/FileEditPayload.cpp b/sources/session/FileEditPayload.cpp new file mode 100644 index 0000000..cda1b8c --- /dev/null +++ b/sources/session/FileEditPayload.cpp @@ -0,0 +1,49 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/FileEditPayload.hpp" + +#include + +namespace QodeAssist::Session { + +namespace { + +QString fileEditMarker() +{ + return QStringLiteral("QODEASSIST_FILE_EDIT:"); +} + +} // namespace + +bool isFileEditPayload(const QString &text) +{ + return text.startsWith(fileEditMarker()); +} + +std::optional parseFileEditPayload(const QString &text) +{ + const QString marker = fileEditMarker(); + const int markerPos = text.indexOf(marker); + if (markerPos < 0) + return std::nullopt; + + const int jsonStart = markerPos + marker.length(); + if (jsonStart >= text.length()) + return std::nullopt; + + const QJsonDocument document = QJsonDocument::fromJson(text.mid(jsonStart).toUtf8()); + if (!document.isObject()) + return std::nullopt; + + return document.object(); +} + +QString encodeFileEditPayload(const QJsonObject &payload) +{ + return fileEditMarker() + + QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact)); +} + +} // namespace QodeAssist::Session diff --git a/sources/session/FileEditPayload.hpp b/sources/session/FileEditPayload.hpp new file mode 100644 index 0000000..5353ac3 --- /dev/null +++ b/sources/session/FileEditPayload.hpp @@ -0,0 +1,18 @@ +// 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 +#include + +#include + +namespace QodeAssist::Session { + +bool isFileEditPayload(const QString &text); +std::optional parseFileEditPayload(const QString &text); +QString encodeFileEditPayload(const QJsonObject &payload); + +} // namespace QodeAssist::Session diff --git a/sources/session/HistoryProjection.cpp b/sources/session/HistoryProjection.cpp new file mode 100644 index 0000000..b39f9d7 --- /dev/null +++ b/sources/session/HistoryProjection.cpp @@ -0,0 +1,244 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/HistoryProjection.hpp" + +#include +#include + +namespace QodeAssist::Session { + +namespace { + +QString signatureSuffix(const QString &signature) +{ + return QString("\n[Signature: %1...]").arg(signature.left(40)); +} + +QString redactedThinkingText() +{ + return QStringLiteral("[Thinking content redacted by safety systems]"); +} + +QString thinkingDisplayText(const ThinkingBlock &block) +{ + const QString text = block.redacted && block.text.isEmpty() ? redactedThinkingText() + : block.text; + return block.signature.isEmpty() ? text : text + signatureSuffix(block.signature); +} + +QString thinkingTextOf(const MessageRow &row) +{ + QString text = row.content; + if (!row.signature.isEmpty()) { + const QString suffix = signatureSuffix(row.signature); + if (text.endsWith(suffix)) + text.chop(suffix.size()); + } + + if (row.redacted && text == redactedThinkingText()) + return {}; + + return text; +} + +QString toolDisplayText(const ToolCallBlock &block) +{ + if (block.name.isEmpty()) + return block.result; + return block.result.isEmpty() ? block.name : block.name + "\n" + block.result; +} + +ToolCallBlock toolCallOf(const MessageRow &row) +{ + ToolCallBlock block{row.id, row.toolName, row.toolArguments, row.toolResult}; + if (block.name.isEmpty() && block.result.isEmpty()) + block.result = row.content; + return block; +} + +RowKind textRowKind(MessageRole role) +{ + switch (role) { + case MessageRole::System: + return RowKind::System; + case MessageRole::User: + return RowKind::User; + case MessageRole::Assistant: + return RowKind::Assistant; + } + return RowKind::Assistant; +} + +} // namespace + +std::optional projectBlockToRow(const Message &message, const ContentBlock &block) +{ + if (const auto *text = std::get_if(&block)) { + MessageRow row; + row.kind = textRowKind(message.role); + row.id = message.id; + row.content = text->text; + return row; + } + + if (const auto *thinking = std::get_if(&block)) { + MessageRow row; + row.kind = RowKind::Thinking; + row.id = message.id; + row.content = thinkingDisplayText(*thinking); + row.redacted = thinking->redacted; + row.signature = thinking->signature; + return row; + } + + if (const auto *tool = std::get_if(&block)) { + MessageRow row; + row.kind = RowKind::Tool; + row.id = tool->id; + row.content = toolDisplayText(*tool); + row.toolName = tool->name; + row.toolArguments = tool->arguments; + row.toolResult = tool->result; + return row; + } + + if (const auto *edit = std::get_if(&block)) { + MessageRow row; + row.kind = RowKind::FileEdit; + row.id = edit->id; + row.content = edit->payload; + return row; + } + + return std::nullopt; +} + +QList projectMessageToRows(const Message &message) +{ + QList rows; + + bool usageAssigned = message.usage.isEmpty(); + qsizetype textRowIndex = -1; + + auto appendRow = [&](MessageRow row) { + if (!usageAssigned && row.id == message.id) { + row.usage = message.usage; + usageAssigned = true; + } + rows.append(std::move(row)); + return rows.size() - 1; + }; + + auto ensureTextRow = [&] { + if (textRowIndex < 0) { + MessageRow row; + row.kind = textRowKind(message.role); + row.id = message.id; + textRowIndex = appendRow(row); + } + return textRowIndex; + }; + + for (const ContentBlock &block : message.blocks) { + if (auto row = projectBlockToRow(message, block)) { + const qsizetype index = appendRow(std::move(*row)); + if (std::holds_alternative(block)) + textRowIndex = index; + } else if (const auto *attachment = std::get_if(&block)) { + rows[ensureTextRow()].attachments.append(*attachment); + } else if (const auto *image = std::get_if(&block)) { + rows[ensureTextRow()].images.append(*image); + } + } + + return rows; +} + +QList projectToRows(const ConversationHistory &history) +{ + QList rows; + + for (const Message &message : history.messages()) + rows.append(projectMessageToRows(message)); + + return rows; +} + +ConversationHistory buildFromRows(const QList &rows) +{ + ConversationHistory history; + std::optional assistant; + + auto flush = [&] { + if (assistant) { + history.append(*assistant); + assistant.reset(); + } + }; + + auto openAssistant = [&]() -> Message & { + if (!assistant) { + Message message; + message.role = MessageRole::Assistant; + assistant = message; + } + return *assistant; + }; + + for (const MessageRow &row : rows) { + switch (row.kind) { + case RowKind::User: + case RowKind::System: { + flush(); + Message message; + message.role = row.kind == RowKind::User ? MessageRole::User : MessageRole::System; + message.id = row.id; + message.usage = row.usage; + message.blocks.append(TextBlock{row.content}); + for (const AttachmentBlock &attachment : row.attachments) + message.blocks.append(attachment); + for (const ImageBlock &image : row.images) + message.blocks.append(image); + history.append(message); + break; + } + case RowKind::Assistant: + case RowKind::Thinking: { + if (assistant && !row.id.isEmpty() && !assistant->id.isEmpty() + && assistant->id != row.id) { + flush(); + } + Message &message = openAssistant(); + if (message.id.isEmpty()) + message.id = row.id; + if (row.kind == RowKind::Assistant) { + message.blocks.append(TextBlock{row.content}); + } else { + message.blocks.append( + ThinkingBlock{thinkingTextOf(row), row.signature, row.redacted}); + } + if (!row.usage.isEmpty()) + message.usage = row.usage; + break; + } + case RowKind::Tool: { + Message &message = openAssistant(); + message.blocks.append(toolCallOf(row)); + break; + } + case RowKind::FileEdit: { + Message &message = openAssistant(); + message.blocks.append(FileEditBlock{row.id, row.content}); + break; + } + } + } + + flush(); + + return history; +} + +} // namespace QodeAssist::Session diff --git a/sources/session/HistoryProjection.hpp b/sources/session/HistoryProjection.hpp new file mode 100644 index 0000000..5ed421c --- /dev/null +++ b/sources/session/HistoryProjection.hpp @@ -0,0 +1,51 @@ +// 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 +#include +#include + +#include + +#include "session/ConversationHistory.hpp" + +namespace QodeAssist::Session { + +enum class RowKind { User, Assistant, System, Tool, FileEdit, Thinking }; + +struct MessageRow +{ + RowKind kind = RowKind::User; + QString id; + QString content; + bool redacted = false; + QString signature; + QString toolName; + QJsonObject toolArguments; + QString toolResult; + QList attachments; + QList images; + Usage usage; + + bool operator==(const MessageRow &other) const = default; + + friend QDebug operator<<(QDebug debug, const MessageRow &row) + { + return debug.nospace() << "Row(kind=" << int(row.kind) << ", id=" << row.id + << ", content=" << row.content << ", sig=" << row.signature + << ", redacted=" << row.redacted << ", tool=" << row.toolName + << ", args=" << row.toolArguments << ", result=" << row.toolResult + << ", attachments=" << row.attachments << ", images=" << row.images + << ", " << row.usage << ")"; + } +}; + +std::optional projectBlockToRow(const Message &message, const ContentBlock &block); +QList projectMessageToRows(const Message &message); +QList projectToRows(const ConversationHistory &history); +ConversationHistory buildFromRows(const QList &rows); + +} // namespace QodeAssist::Session diff --git a/sources/session/HistorySerializer.cpp b/sources/session/HistorySerializer.cpp new file mode 100644 index 0000000..7b5d241 --- /dev/null +++ b/sources/session/HistorySerializer.cpp @@ -0,0 +1,290 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/HistorySerializer.hpp" + +#include + +#include "session/HistoryProjection.hpp" + +namespace QodeAssist::Session { + +namespace { + +QString roleToString(MessageRole role) +{ + switch (role) { + case MessageRole::System: + return QStringLiteral("system"); + case MessageRole::User: + return QStringLiteral("user"); + case MessageRole::Assistant: + return QStringLiteral("assistant"); + } + return QStringLiteral("assistant"); +} + +MessageRole roleFromString(const QString &role) +{ + if (role == QLatin1String("system")) + return MessageRole::System; + if (role == QLatin1String("user")) + return MessageRole::User; + return MessageRole::Assistant; +} + +RowKind legacyRowKind(int role) +{ + switch (role) { + case 0: + return RowKind::System; + case 1: + return RowKind::User; + case 2: + return RowKind::Assistant; + case 3: + return RowKind::Tool; + case 4: + return RowKind::FileEdit; + case 5: + return RowKind::Thinking; + default: + return RowKind::Assistant; + } +} + +QJsonObject usageToJson(const Usage &usage) +{ + QJsonObject json; + json["promptTokens"] = usage.promptTokens; + json["completionTokens"] = usage.completionTokens; + if (usage.cachedPromptTokens > 0) + json["cachedPromptTokens"] = usage.cachedPromptTokens; + if (usage.reasoningTokens > 0) + json["reasoningTokens"] = usage.reasoningTokens; + return json; +} + +Usage usageFromJson(const QJsonObject &json) +{ + Usage usage; + usage.promptTokens = json["promptTokens"].toInt(); + usage.completionTokens = json["completionTokens"].toInt(); + usage.cachedPromptTokens = json["cachedPromptTokens"].toInt(); + usage.reasoningTokens = json["reasoningTokens"].toInt(); + return usage; +} + +QJsonObject blockToJson(const ContentBlock &block) +{ + QJsonObject json; + + if (const auto *text = std::get_if(&block)) { + json["type"] = "text"; + json["text"] = text->text; + } else if (const auto *thinking = std::get_if(&block)) { + json["type"] = "thinking"; + json["text"] = thinking->text; + if (!thinking->signature.isEmpty()) + json["signature"] = thinking->signature; + if (thinking->redacted) + json["redacted"] = true; + } else if (const auto *tool = std::get_if(&block)) { + json["type"] = "tool_call"; + json["id"] = tool->id; + json["name"] = tool->name; + if (!tool->arguments.isEmpty()) + json["arguments"] = tool->arguments; + if (!tool->result.isEmpty()) + json["result"] = tool->result; + } else if (const auto *attachment = std::get_if(&block)) { + json["type"] = "attachment"; + json["fileName"] = attachment->fileName; + json["storedPath"] = attachment->storedPath; + } else if (const auto *image = std::get_if(&block)) { + json["type"] = "image"; + json["fileName"] = image->fileName; + json["storedPath"] = image->storedPath; + json["mediaType"] = image->mediaType; + } else if (const auto *edit = std::get_if(&block)) { + json["type"] = "file_edit"; + json["id"] = edit->id; + json["payload"] = edit->payload; + } + + return json; +} + +std::optional blockFromJson(const QJsonObject &json) +{ + const QString type = json["type"].toString(); + + if (type == QLatin1String("text")) + return ContentBlock{TextBlock{json["text"].toString()}}; + + if (type == QLatin1String("thinking")) { + return ContentBlock{ThinkingBlock{ + json["text"].toString(), json["signature"].toString(), json["redacted"].toBool(false)}}; + } + + if (type == QLatin1String("tool_call")) { + return ContentBlock{ToolCallBlock{ + json["id"].toString(), + json["name"].toString(), + json["arguments"].toObject(), + json["result"].toString()}}; + } + + if (type == QLatin1String("attachment")) + return ContentBlock{ + AttachmentBlock{json["fileName"].toString(), json["storedPath"].toString()}}; + + if (type == QLatin1String("image")) { + return ContentBlock{ImageBlock{ + json["fileName"].toString(), + json["storedPath"].toString(), + json["mediaType"].toString()}}; + } + + if (type == QLatin1String("file_edit")) + return ContentBlock{FileEditBlock{json["id"].toString(), json["payload"].toString()}}; + + return std::nullopt; +} + +QJsonObject messageToJson(const Message &message) +{ + QJsonArray blocks; + for (const ContentBlock &block : message.blocks) + blocks.append(blockToJson(block)); + + QJsonObject json; + json["role"] = roleToString(message.role); + if (!message.id.isEmpty()) + json["id"] = message.id; + json["blocks"] = blocks; + if (!message.usage.isEmpty()) + json["usage"] = usageToJson(message.usage); + + return json; +} + +Message messageFromJson(const QJsonObject &json, int &droppedBlocks) +{ + Message message; + message.role = roleFromString(json["role"].toString()); + message.id = json["id"].toString(); + + const QJsonArray blocks = json["blocks"].toArray(); + for (const QJsonValue &value : blocks) { + if (auto block = blockFromJson(value.toObject())) + message.blocks.append(*block); + else + ++droppedBlocks; + } + + if (json.contains("usage")) + message.usage = usageFromJson(json["usage"].toObject()); + + return message; +} + +MessageRow legacyRowFromJson(const QJsonObject &json) +{ + MessageRow row; + row.kind = legacyRowKind(json["role"].toInt()); + row.id = json["id"].toString(); + row.content = json["content"].toString(); + row.redacted = json["isRedacted"].toBool(false); + row.signature = json["signature"].toString(); + row.toolName = json["toolName"].toString(); + row.toolArguments = json["toolArguments"].toObject(); + row.toolResult = json["toolResult"].toString(); + + const QJsonArray attachments = json["attachments"].toArray(); + for (const QJsonValue &value : attachments) { + const QJsonObject attachment = value.toObject(); + row.attachments.append( + AttachmentBlock{attachment["fileName"].toString(), attachment["storedPath"].toString()}); + } + + const QJsonArray images = json["images"].toArray(); + for (const QJsonValue &value : images) { + const QJsonObject image = value.toObject(); + row.images.append( + ImageBlock{ + image["fileName"].toString(), + image["storedPath"].toString(), + image["mediaType"].toString()}); + } + + if (json.contains("usage")) + row.usage = usageFromJson(json["usage"].toObject()); + + return row; +} + +ConversationHistory historyFromLegacyJson(const QJsonObject &root) +{ + QList rows; + const QJsonArray messages = root["messages"].toArray(); + for (const QJsonValue &value : messages) + rows.append(legacyRowFromJson(value.toObject())); + + return buildFromRows(rows); +} + +} // namespace + +QString HistorySerializer::currentVersion() +{ + return QStringLiteral("0.4"); +} + +bool HistorySerializer::isSupportedVersion(const QString &version) +{ + return version == currentVersion() || version == QLatin1String("0.2") + || version == QLatin1String("0.1"); +} + +QJsonObject HistorySerializer::toJson(const ConversationHistory &history) +{ + QJsonArray messages; + for (const Message &message : history.messages()) + messages.append(messageToJson(message)); + + QJsonObject root; + root["version"] = currentVersion(); + root["messages"] = messages; + + return root; +} + +std::optional HistorySerializer::fromJson( + const QJsonObject &root, int *droppedBlocks) +{ + if (droppedBlocks) + *droppedBlocks = 0; + + const QString version = root["version"].toString(); + + if (!isSupportedVersion(version) || !root["messages"].isArray()) + return std::nullopt; + + if (version != currentVersion()) + return historyFromLegacyJson(root); + + int dropped = 0; + ConversationHistory history; + const QJsonArray messages = root["messages"].toArray(); + for (const QJsonValue &value : messages) + history.append(messageFromJson(value.toObject(), dropped)); + + if (droppedBlocks) + *droppedBlocks = dropped; + + return history; +} + +} // namespace QodeAssist::Session diff --git a/sources/session/HistorySerializer.hpp b/sources/session/HistorySerializer.hpp new file mode 100644 index 0000000..a6a9a67 --- /dev/null +++ b/sources/session/HistorySerializer.hpp @@ -0,0 +1,27 @@ +// 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 +#include + +#include + +#include "session/ConversationHistory.hpp" + +namespace QodeAssist::Session { + +class HistorySerializer +{ +public: + static QString currentVersion(); + static bool isSupportedVersion(const QString &version); + + static QJsonObject toJson(const ConversationHistory &history); + static std::optional fromJson( + const QJsonObject &root, int *droppedBlocks = nullptr); +}; + +} // namespace QodeAssist::Session diff --git a/sources/session/Message.hpp b/sources/session/Message.hpp new file mode 100644 index 0000000..3144c9b --- /dev/null +++ b/sources/session/Message.hpp @@ -0,0 +1,55 @@ +// 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 +#include + +#include "session/ContentBlock.hpp" + +namespace QodeAssist::Session { + +enum class MessageRole { User, Assistant, System }; + +struct Usage +{ + int promptTokens = 0; + int completionTokens = 0; + int cachedPromptTokens = 0; + int reasoningTokens = 0; + + bool isEmpty() const + { + return promptTokens == 0 && completionTokens == 0 && cachedPromptTokens == 0 + && reasoningTokens == 0; + } + + bool operator==(const Usage &other) const = default; + + friend QDebug operator<<(QDebug debug, const Usage &usage) + { + return debug.nospace() << "Usage(" << usage.promptTokens << ", " << usage.completionTokens + << ", " << usage.cachedPromptTokens << ", " << usage.reasoningTokens + << ")"; + } +}; + +struct Message +{ + MessageRole role = MessageRole::User; + QString id; + QList blocks; + Usage usage; + + bool operator==(const Message &other) const = default; + + friend QDebug operator<<(QDebug debug, const Message &message) + { + return debug.nospace() << "Message(role=" << int(message.role) << ", id=" << message.id + << ", blocks=" << message.blocks << ", " << message.usage << ")"; + } +}; + +} // namespace QodeAssist::Session diff --git a/sources/session/Session.cpp b/sources/session/Session.cpp new file mode 100644 index 0000000..51f2197 --- /dev/null +++ b/sources/session/Session.cpp @@ -0,0 +1,427 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/Session.hpp" + +#include + +#include + +#include "session/FileEditPayload.hpp" + +namespace QodeAssist::Session { + +namespace { + +std::optional fileEditFromToolResult(const QString &result) +{ + if (!isFileEditPayload(result)) + return std::nullopt; + + auto payload = parseFileEditPayload(result); + if (!payload) + return std::nullopt; + + const QString editId = payload->value("edit_id").toString(); + if (!editId.isEmpty()) + return FileEditBlock{.id = editId, .payload = result}; + + const QString generatedId = QString("edit_%1").arg( + QUuid::createUuid().toString(QUuid::WithoutBraces)); + payload->insert("edit_id", generatedId); + + return FileEditBlock{.id = generatedId, .payload = encodeFileEditPayload(*payload)}; +} + +Message truncateMessage(const Message &message, qsizetype rowsWanted) +{ + Message truncated = message; + qsizetype keptBlocks = 0; + + for (qsizetype i = 0; i < message.blocks.size(); ++i) { + Message probe = message; + probe.blocks = message.blocks.mid(0, i + 1); + if (projectMessageToRows(probe).size() > rowsWanted) + break; + keptBlocks = i + 1; + } + + truncated.blocks = message.blocks.mid(0, keptBlocks); + return truncated; +} + +bool continuesThinking(const ThinkingBlock &block, const ThinkingReceived &event) +{ + return !block.redacted && !event.redacted && event.text.startsWith(block.text) + && (block.signature.isEmpty() || block.signature == event.signature); +} + +} // namespace + +Session::Session(QObject *parent) + : QObject(parent) +{} + +void Session::setBackend(ChatBackend *backend) +{ + if (m_backend == backend) + return; + + if (m_backend) + disconnect(m_backend, nullptr, this, nullptr); + + m_backend = backend; + + if (m_backend) { + connect(m_backend, &ChatBackend::sessionEvent, this, &Session::handleEvent); + } +} + +const ConversationHistory &Session::history() const +{ + return m_history; +} + +const QList &Session::rows() const +{ + return m_rows; +} + +void Session::setHistory(const ConversationHistory &history) +{ + cancel(); + m_history = history; + m_rows = projectToRows(m_history); + emit rowsReset(m_rows); +} + +void Session::clear() +{ + setHistory(ConversationHistory{}); +} + +void Session::truncateRows(int rowIndex) +{ + if (rowIndex < 0 || rowIndex >= m_rows.size()) + return; + + cancel(); + + ConversationHistory kept; + qsizetype rowsKept = 0; + + for (const Message &message : m_history.messages()) { + const qsizetype rows = projectMessageToRows(message).size(); + if (rowsKept + rows > rowIndex) { + const qsizetype rowsWanted = rowIndex - rowsKept; + if (rowsWanted > 0) + kept.append(truncateMessage(message, rowsWanted)); + break; + } + kept.append(message); + rowsKept += rows; + } + + const int removed = static_cast(m_rows.size()) - rowIndex; + m_rows.remove(rowIndex, removed); + m_history = kept; + + emit rowsRemoved(rowIndex, removed); +} + +void Session::sendTurn( + const QList &userBlocks, + const std::optional &context, + const TurnOptions &options) +{ + if (!m_backend) + return; + + cancel(); + + Message message; + message.role = MessageRole::User; + message.blocks = userBlocks; + appendMessage(message); + + TurnRequest request; + request.userBlocks = userBlocks; + request.history = &m_history; + request.context = context; + request.options = options; + + m_backend->sendTurn(request); +} + +void Session::cancel() +{ + endTurn(); + + if (m_backend) + m_backend->cancel(); +} + +bool Session::updateFileEditStatus( + const QString &editId, const QString &status, const QString &statusMessage) +{ + QStringList encodedPayloads; + + m_history.visitBlocks([&](ContentBlock &block) { + auto *edit = std::get_if(&block); + if (!edit || edit->id != editId) + return; + + auto payload = parseFileEditPayload(edit->payload); + if (!payload) + return; + + payload->insert("status", status); + if (!statusMessage.isEmpty()) + payload->insert("status_message", statusMessage); + + edit->payload = encodeFileEditPayload(*payload); + encodedPayloads.append(edit->payload); + }); + + if (encodedPayloads.isEmpty()) + return false; + + qsizetype matched = 0; + for (int i = 0; i < m_rows.size() && matched < encodedPayloads.size(); ++i) { + if (m_rows[i].kind != RowKind::FileEdit || m_rows[i].id != editId) + continue; + m_rows[i].content = encodedPayloads.at(matched++); + const MessageRow updated = m_rows.at(i); + emit rowUpdated(i, updated); + } + + return true; +} + +void Session::handleEvent(const SessionEvent &event) +{ + static_assert( + std::variant_size_v == 8, + "SessionEvent gained an alternative; extend the dispatch below or it is silently dropped"); + + if (const auto *started = std::get_if(&event)) { + m_activeTurnId = started->turnId; + m_assistantRowStart = -1; + emit turnStarted(started->turnId); + return; + } + + if (const auto *failure = std::get_if(&event)) { + if (!failure->turnId.isEmpty() && failure->turnId != m_activeTurnId) + return; + const QString error = failure->error; + endTurn(); + emit turnFailed(error); + return; + } + + if (m_activeTurnId.isEmpty() || turnIdOf(event) != m_activeTurnId) + return; + + if (const auto *delta = std::get_if(&event)) { + m_textSegment += delta->text; + + if (!m_textSegment.isEmpty() && m_textSegment.at(0).isSpace()) { + qsizetype content = 0; + while (content < m_textSegment.size() && m_textSegment.at(content).isSpace()) + ++content; + m_textSegment.remove(0, content); + } + + if (m_textSegment.isEmpty()) + return; + + const QString text = m_textSegment.trimmed(); + + mutateAssistantTail([&text](Message &message) { + if (!message.blocks.isEmpty()) { + if (auto *last = std::get_if(&message.blocks.last())) { + last->text = text; + return; + } + } + message.blocks.append(TextBlock{text}); + }); + } else if (const auto *thinking = std::get_if(&event)) { + m_textSegment.clear(); + mutateAssistantTail([thinking](Message &message) { + if (!message.blocks.isEmpty()) { + if (auto *last = std::get_if(&message.blocks.last()); + last && continuesThinking(*last, *thinking)) { + last->text = thinking->text; + last->signature = thinking->signature; + return; + } + } + message.blocks.append( + ThinkingBlock{ + .text = thinking->text, + .signature = thinking->signature, + .redacted = thinking->redacted}); + }); + } else if (const auto *toolStart = std::get_if(&event)) { + m_textSegment.clear(); + mutateAssistant([toolStart](Message &message) { + if (toolStart->dropPrecedingText && !message.blocks.isEmpty() + && std::holds_alternative(message.blocks.last())) { + message.blocks.removeLast(); + } + message.blocks.append( + ToolCallBlock{ + .id = toolStart->toolId, + .name = toolStart->name, + .arguments = toolStart->arguments, + .result = {}}); + }); + } else if (const auto *toolEnd = std::get_if(&event)) { + m_textSegment.clear(); + mutateAssistant([toolEnd](Message &message) { + for (auto it = message.blocks.rbegin(); it != message.blocks.rend(); ++it) { + auto *tool = std::get_if(&*it); + if (!tool || tool->id != toolEnd->toolId) + continue; + tool->name = toolEnd->name; + tool->result = toolEnd->result; + break; + } + if (const auto edit = fileEditFromToolResult(toolEnd->result)) + message.blocks.append(*edit); + }); + } else if (const auto *usage = std::get_if(&event)) { + if (Message *assistant = activeAssistantMessage()) { + assistant->usage = usage->usage; + syncAssistantRows(); + } + emit usageReceived(usage->usage); + } else if (const auto *completed = std::get_if(&event)) { + const QString turnId = completed->turnId; + endTurn(); + emit turnFinished(turnId); + } +} + +void Session::appendMessage(const Message &message) +{ + m_history.append(message); + + const QList rows = projectMessageToRows(message); + if (rows.isEmpty()) + return; + + m_rows.append(rows); + emit rowsAppended(rows); +} + +void Session::ensureAssistantMessage() +{ + if (m_assistantRowStart >= 0) + return; + + Message message; + message.role = MessageRole::Assistant; + message.id = m_activeTurnId; + m_assistantRowStart = static_cast(m_rows.size()); + m_history.append(message); +} + +Message *Session::activeAssistantMessage() +{ + return m_assistantRowStart < 0 ? nullptr : m_history.lastMessage(); +} + +void Session::mutateAssistant(const std::function &mutate) +{ + ensureAssistantMessage(); + + Message *assistant = activeAssistantMessage(); + if (!assistant) + return; + + mutate(*assistant); + syncAssistantRows(); +} + +void Session::mutateAssistantTail(const std::function &mutate) +{ + ensureAssistantMessage(); + + Message *assistant = activeAssistantMessage(); + if (!assistant) + return; + + const qsizetype blocksBefore = assistant->blocks.size(); + mutate(*assistant); + + if (assistant->blocks.size() == blocksBefore) + updateLastAssistantRow(); + else + syncAssistantRows(); +} + +void Session::updateLastAssistantRow() +{ + const Message *assistant = activeAssistantMessage(); + const int index = static_cast(m_rows.size()) - 1; + if (!assistant || index < m_assistantRowStart || assistant->blocks.isEmpty()) { + syncAssistantRows(); + return; + } + + auto fresh = projectBlockToRow(*assistant, assistant->blocks.last()); + if (!fresh) { + syncAssistantRows(); + return; + } + + fresh->usage = m_rows.at(index).usage; + if (m_rows.at(index) == *fresh) + return; + + const MessageRow updated = *fresh; + m_rows[index] = updated; + emit rowUpdated(index, updated); +} + +void Session::syncAssistantRows() +{ + const Message *assistant = activeAssistantMessage(); + if (!assistant) + return; + + const QList fresh = projectMessageToRows(*assistant); + const int start = m_assistantRowStart; + const int previous = static_cast(m_rows.size()) - start; + + const int common = std::min(previous, static_cast(fresh.size())); + for (int i = 0; i < common; ++i) { + if (m_rows.at(start + i) == fresh.at(i)) + continue; + m_rows[start + i] = fresh[i]; + emit rowUpdated(start + i, fresh[i]); + } + + if (fresh.size() > previous) { + const QList added = fresh.mid(previous); + m_rows.append(added); + emit rowsAppended(added); + } else if (fresh.size() < previous) { + const int removed = previous - static_cast(fresh.size()); + m_rows.remove(start + fresh.size(), removed); + emit rowsRemoved(start + static_cast(fresh.size()), removed); + } +} + +void Session::endTurn() +{ + m_activeTurnId.clear(); + m_textSegment.clear(); + m_assistantRowStart = -1; +} + +} // namespace QodeAssist::Session diff --git a/sources/session/Session.hpp b/sources/session/Session.hpp new file mode 100644 index 0000000..6f74937 --- /dev/null +++ b/sources/session/Session.hpp @@ -0,0 +1,78 @@ +// 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 +#include +#include +#include + +#include +#include + +#include "session/ChatBackend.hpp" +#include "session/ConversationHistory.hpp" +#include "session/HistoryProjection.hpp" +#include "session/SessionEvent.hpp" +#include "session/TurnContext.hpp" +#include "session/TurnRequest.hpp" + +namespace QodeAssist::Session { + +class Session : public QObject +{ + Q_OBJECT + +public: + explicit Session(QObject *parent = nullptr); + + void setBackend(ChatBackend *backend); + + const ConversationHistory &history() const; + const QList &rows() const; + + void setHistory(const ConversationHistory &history); + void clear(); + void truncateRows(int rowIndex); + + void sendTurn( + const QList &userBlocks, + const std::optional &context, + const TurnOptions &options); + void cancel(); + + bool updateFileEditStatus( + const QString &editId, const QString &status, const QString &statusMessage = {}); + +signals: + void rowsAppended(const QList &rows); + void rowUpdated(int index, const QodeAssist::Session::MessageRow &row); + void rowsRemoved(int first, int count); + void rowsReset(const QList &rows); + void turnStarted(const QString &turnId); + void turnFinished(const QString &turnId); + void turnFailed(const QString &error); + void usageReceived(const QodeAssist::Session::Usage &usage); + +private: + void handleEvent(const SessionEvent &event); + void appendMessage(const Message &message); + Message *activeAssistantMessage(); + void mutateAssistant(const std::function &mutate); + void mutateAssistantTail(const std::function &mutate); + void ensureAssistantMessage(); + void updateLastAssistantRow(); + void syncAssistantRows(); + void endTurn(); + + ConversationHistory m_history; + QList m_rows; + QPointer m_backend; + QString m_activeTurnId; + QString m_textSegment; + int m_assistantRowStart = -1; +}; + +} // namespace QodeAssist::Session diff --git a/sources/session/SessionEvent.cpp b/sources/session/SessionEvent.cpp new file mode 100644 index 0000000..996fa90 --- /dev/null +++ b/sources/session/SessionEvent.cpp @@ -0,0 +1,14 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/SessionEvent.hpp" + +namespace QodeAssist::Session { + +QString turnIdOf(const SessionEvent &event) +{ + return std::visit([](const auto &alternative) { return alternative.turnId; }, event); +} + +} // namespace QodeAssist::Session diff --git a/sources/session/SessionEvent.hpp b/sources/session/SessionEvent.hpp new file mode 100644 index 0000000..88b7bd4 --- /dev/null +++ b/sources/session/SessionEvent.hpp @@ -0,0 +1,100 @@ +// 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 +#include +#include + +#include + +#include "session/Message.hpp" + +namespace QodeAssist::Session { + +struct TurnStarted +{ + QString turnId; + + bool operator==(const TurnStarted &other) const = default; +}; + +struct TextDelta +{ + QString turnId; + QString text; + + bool operator==(const TextDelta &other) const = default; +}; + +struct ThinkingReceived +{ + QString turnId; + QString text; + QString signature; + bool redacted = false; + + bool operator==(const ThinkingReceived &other) const = default; +}; + +struct ToolCallStarted +{ + QString turnId; + QString toolId; + QString name; + QJsonObject arguments; + bool dropPrecedingText = false; + + bool operator==(const ToolCallStarted &other) const = default; +}; + +struct ToolCallCompleted +{ + QString turnId; + QString toolId; + QString name; + QString result; + + bool operator==(const ToolCallCompleted &other) const = default; +}; + +struct UsageReported +{ + QString turnId; + Usage usage; + + bool operator==(const UsageReported &other) const = default; +}; + +struct TurnCompleted +{ + QString turnId; + + bool operator==(const TurnCompleted &other) const = default; +}; + +struct TurnFailed +{ + QString turnId; + QString error; + + bool operator==(const TurnFailed &other) const = default; +}; + +using SessionEvent = std::variant< + TurnStarted, + TextDelta, + ThinkingReceived, + ToolCallStarted, + ToolCallCompleted, + UsageReported, + TurnCompleted, + TurnFailed>; + +QString turnIdOf(const SessionEvent &event); + +} // namespace QodeAssist::Session + +Q_DECLARE_METATYPE(QodeAssist::Session::SessionEvent) diff --git a/sources/session/TurnContext.cpp b/sources/session/TurnContext.cpp new file mode 100644 index 0000000..df45620 --- /dev/null +++ b/sources/session/TurnContext.cpp @@ -0,0 +1,56 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/TurnContext.hpp" + +namespace QodeAssist::Session { + +QString renderSystemPrompt(const TurnContext &context) +{ + QString prompt = context.basePrompt; + + if (context.rolePrompt) + prompt += "\n\n" + *context.rolePrompt; + + if (context.project.available) { + prompt += QString("\n# Active project: %1").arg(context.project.displayName); + prompt += QString( + "\n# Project source root: %1" + "\n# All new source files, headers, QML and CMake edits MUST be " + "created or modified under this directory. Use absolute paths " + "rooted here, or project-relative paths.") + .arg(context.project.sourceRoot); + + if (context.project.buildDirectory) { + prompt += QString( + "\n# Build output directory (compiler artifacts only — do NOT " + "create or edit source files here): %1") + .arg(*context.project.buildDirectory); + } + + if (!context.projectRules.isEmpty()) + prompt += QString("\n# Project Rules\n\n") + context.projectRules; + } else { + prompt += QString("\n# No active project in IDE"); + } + + if (!context.alwaysOnSkills.isEmpty()) + prompt += QString("\n\n") + context.alwaysOnSkills; + + if (!context.skillsCatalog.isEmpty()) + prompt += QString("\n\n") + context.skillsCatalog; + + for (const InvokedSkill &skill : context.invokedSkills) + prompt += QString("\n\n# Invoked Skill: %1\n\n%2").arg(skill.name, skill.body); + + if (!context.linkedFilePaths.isEmpty()) { + prompt += "\n\nLinked files for reference:\n"; + for (const LinkedFile &file : context.linkedFiles) + prompt += QString("\nFile: %1\nContent:\n%2\n").arg(file.fileName, file.content); + } + + return prompt; +} + +} // namespace QodeAssist::Session diff --git a/sources/session/TurnContext.hpp b/sources/session/TurnContext.hpp new file mode 100644 index 0000000..3a7d287 --- /dev/null +++ b/sources/session/TurnContext.hpp @@ -0,0 +1,57 @@ +// 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 +#include + +#include + +namespace QodeAssist::Session { + +struct ProjectInfo +{ + bool available = false; + QString displayName; + QString sourceRoot; + std::optional buildDirectory; + + bool operator==(const ProjectInfo &other) const = default; +}; + +struct InvokedSkill +{ + QString name; + QString body; + + bool operator==(const InvokedSkill &other) const = default; +}; + +struct LinkedFile +{ + QString fileName; + QString content; + + bool operator==(const LinkedFile &other) const = default; +}; + +struct TurnContext +{ + QString basePrompt; + std::optional rolePrompt; + ProjectInfo project; + QString projectRules; + QString alwaysOnSkills; + QString skillsCatalog; + QList invokedSkills; + QList linkedFilePaths; + QList linkedFiles; + + bool operator==(const TurnContext &other) const = default; +}; + +QString renderSystemPrompt(const TurnContext &context); + +} // namespace QodeAssist::Session diff --git a/sources/session/TurnContextBuilder.cpp b/sources/session/TurnContextBuilder.cpp new file mode 100644 index 0000000..c02a030 --- /dev/null +++ b/sources/session/TurnContextBuilder.cpp @@ -0,0 +1,58 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/TurnContextBuilder.hpp" + +#include +#include + +namespace QodeAssist::Session { + +TurnContextBuilder::TurnContextBuilder( + const IProjectContextPort &project, + const ISkillsContextPort *skills, + const ILinkedFilesPort &linkedFiles) + : m_project(project) + , m_skills(skills) + , m_linkedFiles(linkedFiles) +{} + +TurnContext TurnContextBuilder::build(const TurnContextRequest &request) const +{ + TurnContext context; + context.basePrompt = request.basePrompt; + context.rolePrompt = request.rolePrompt; + + context.project = m_project.projectInfo(); + if (context.project.available) + context.projectRules = m_project.projectRules(); + + if (m_skills) { + context.alwaysOnSkills = m_skills->alwaysOnBodies(); + context.skillsCatalog = m_skills->catalogText(); + + static const QRegularExpression skillCommand( + QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)")); + QStringList invokedSkillNames; + auto skillMatch = skillCommand.globalMatch(request.message); + while (skillMatch.hasNext()) { + const QString skillName = skillMatch.next().captured(1); + if (invokedSkillNames.contains(skillName)) + continue; + const auto invokedSkill = m_skills->findSkill(skillName); + if (invokedSkill && !invokedSkill->body.isEmpty()) { + invokedSkillNames << skillName; + context.invokedSkills.append(*invokedSkill); + } + } + } + + context.linkedFilePaths = request.linkedFilePaths; + if (!request.linkedFilePaths.isEmpty()) + context.linkedFiles = m_linkedFiles.readFiles(request.linkedFilePaths); + + return context; +} + +} // namespace QodeAssist::Session diff --git a/sources/session/TurnContextBuilder.hpp b/sources/session/TurnContextBuilder.hpp new file mode 100644 index 0000000..d1c97d2 --- /dev/null +++ b/sources/session/TurnContextBuilder.hpp @@ -0,0 +1,46 @@ +// 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 +#include + +#include + +#include "session/TurnContext.hpp" +#include "session/TurnContextPorts.hpp" + +namespace QodeAssist::Session { + +struct TurnContextRequest +{ + QString message; + QString basePrompt; + std::optional rolePrompt; + QList linkedFilePaths; +}; + +class TurnContextBuilder +{ +public: + TurnContextBuilder( + const IProjectContextPort &project, + const ISkillsContextPort *skills, + const ILinkedFilesPort &linkedFiles); + + TurnContextBuilder(IProjectContextPort &&, const ISkillsContextPort *, const ILinkedFilesPort &) + = delete; + TurnContextBuilder(const IProjectContextPort &, const ISkillsContextPort *, ILinkedFilesPort &&) + = delete; + + TurnContext build(const TurnContextRequest &request) const; + +private: + const IProjectContextPort &m_project; + const ISkillsContextPort *m_skills = nullptr; + const ILinkedFilesPort &m_linkedFiles; +}; + +} // namespace QodeAssist::Session diff --git a/sources/session/TurnContextPorts.hpp b/sources/session/TurnContextPorts.hpp new file mode 100644 index 0000000..e78ed12 --- /dev/null +++ b/sources/session/TurnContextPorts.hpp @@ -0,0 +1,53 @@ +// 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 +#include +#include + +#include + +#include "session/TurnContext.hpp" + +namespace QodeAssist::Session { + +class IProjectContextPort +{ + Q_DISABLE_COPY_MOVE(IProjectContextPort) + +public: + IProjectContextPort() = default; + virtual ~IProjectContextPort() = default; + + virtual ProjectInfo projectInfo() const = 0; + virtual QString projectRules() const = 0; +}; + +class ISkillsContextPort +{ + Q_DISABLE_COPY_MOVE(ISkillsContextPort) + +public: + ISkillsContextPort() = default; + virtual ~ISkillsContextPort() = default; + + virtual QString alwaysOnBodies() const = 0; + virtual QString catalogText() const = 0; + virtual std::optional findSkill(const QString &name) const = 0; +}; + +class ILinkedFilesPort +{ + Q_DISABLE_COPY_MOVE(ILinkedFilesPort) + +public: + ILinkedFilesPort() = default; + virtual ~ILinkedFilesPort() = default; + + virtual QList readFiles(const QList &paths) const = 0; +}; + +} // namespace QodeAssist::Session diff --git a/sources/session/TurnRequest.hpp b/sources/session/TurnRequest.hpp new file mode 100644 index 0000000..3161a45 --- /dev/null +++ b/sources/session/TurnRequest.hpp @@ -0,0 +1,33 @@ +// 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 + +#include + +#include "session/ContentBlock.hpp" +#include "session/ConversationHistory.hpp" +#include "session/TurnContext.hpp" + +namespace QodeAssist::Session { + +struct TurnOptions +{ + bool useTools = false; + bool useThinking = false; + + bool operator==(const TurnOptions &other) const = default; +}; + +struct TurnRequest +{ + QList userBlocks; + const ConversationHistory *history = nullptr; + std::optional context; + TurnOptions options; +}; + +} // namespace QodeAssist::Session diff --git a/sources/tools/ReadOriginalHistoryTool.cpp b/sources/tools/ReadOriginalHistoryTool.cpp index 91422e9..8fec7a9 100644 --- a/sources/tools/ReadOriginalHistoryTool.cpp +++ b/sources/tools/ReadOriginalHistoryTool.cpp @@ -12,28 +12,30 @@ #include #include +#include "session/HistoryProjection.hpp" +#include "session/HistorySerializer.hpp" + namespace QodeAssist::Tools { namespace { -QString roleName(int role) +QString roleName(Session::RowKind kind) { - switch (role) { - case 0: + switch (kind) { + case Session::RowKind::System: return QStringLiteral("system"); - case 1: + case Session::RowKind::User: return QStringLiteral("user"); - case 2: + case Session::RowKind::Assistant: return QStringLiteral("assistant"); - case 3: + case Session::RowKind::Tool: return QStringLiteral("tool"); - case 4: + case Session::RowKind::FileEdit: return QStringLiteral("file_edit"); - case 5: + case Session::RowKind::Thinking: return QStringLiteral("thinking"); - default: - return QStringLiteral("unknown"); } + return QStringLiteral("unknown"); } QJsonObject readJsonObject(const QString &path) @@ -144,8 +146,15 @@ QFuture ReadOriginalHistoryTool::executeAsync(const QJsonOb "history."); } - const QJsonObject root = readJsonObject(rootPath); - const QJsonArray messages = root.value("messages").toArray(); + const auto history = Session::HistorySerializer::fromJson(readJsonObject(rootPath)); + if (!history) { + return LLMQore::ToolResult::text( + QString("Cannot read the pre-compression history at %1: unsupported chat file " + "format.") + .arg(rootPath)); + } + + const QList messages = Session::projectToRows(*history); const QString query = input.value("query").toString().trimmed(); const QString roleFilter = input.value("role").toString().trimmed().toLower(); @@ -155,9 +164,9 @@ QFuture ReadOriginalHistoryTool::executeAsync(const QJsonOb QStringList matched; int matchCount = 0; for (int i = 0; i < messages.size(); ++i) { - const QJsonObject msg = messages.at(i).toObject(); - const QString role = roleName(msg.value("role").toInt()); - const QString content = msg.value("content").toString(); + const Session::MessageRow &msg = messages.at(i); + const QString role = roleName(msg.kind); + const QString content = msg.content; if (!roleFilter.isEmpty() && role != roleFilter) continue; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt deleted file mode 100644 index 91c5890..0000000 --- a/tests/CMakeLists.txt +++ /dev/null @@ -1,30 +0,0 @@ -add_executable(QodeAssistTest - ../sources/completion/CodeHandler.cpp - ../sources/completion/LLMClientInterface.cpp - ../sources/completion/LLMSuggestion.cpp - ../sources/providers/Provider.cpp - CodeHandlerTest.cpp - ClaudeCacheControlTest.cpp - DocumentContextReaderTest.cpp - LLMSuggestionTest.cpp - # LLMClientInterfaceTests.cpp - unittest_main.cpp -) - -target_link_libraries(QodeAssistTest PRIVATE - Qt::Core - Qt::Test - GTest::GTest - GTest::gmock - GTest::Main - QtCreator::LanguageClient - Context - QodeAssistLogger - LLMQore -) - -target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR}/sources) - -target_compile_definitions(QodeAssistTest PRIVATE CMAKE_CURRENT_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") - -add_test(NAME QodeAssistTest COMMAND QodeAssistTest) diff --git a/tests/ClaudeCacheControlTest.cpp b/tests/ClaudeCacheControlTest.cpp deleted file mode 100644 index 86bf688..0000000 --- a/tests/ClaudeCacheControlTest.cpp +++ /dev/null @@ -1,182 +0,0 @@ -// 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 "providers/ClaudeCacheControl.hpp" - -using namespace QodeAssist::Providers::ClaudeCacheControl; - -namespace { - -QJsonObject expectedEphemeral(bool extendedTtl) -{ - QJsonObject obj{{"type", "ephemeral"}}; - if (extendedTtl) - obj["ttl"] = "1h"; - return obj; -} - -} // namespace - -TEST(ClaudeCacheControlTest, BreakpointWithoutExtendedTTL) -{ - const QJsonObject cc = buildBreakpoint(false); - EXPECT_EQ(cc.value("type").toString(), "ephemeral"); - EXPECT_FALSE(cc.contains("ttl")); -} - -TEST(ClaudeCacheControlTest, BreakpointWithExtendedTTL) -{ - const QJsonObject cc = buildBreakpoint(true); - EXPECT_EQ(cc.value("type").toString(), "ephemeral"); - EXPECT_EQ(cc.value("ttl").toString(), "1h"); -} - -TEST(ClaudeCacheControlTest, SystemAsStringWrappedIntoArray) -{ - QJsonObject request; - request["system"] = "you are a helpful agent"; - - apply(request, false); - - ASSERT_TRUE(request.value("system").isArray()); - const QJsonArray sys = request.value("system").toArray(); - ASSERT_EQ(sys.size(), 1); - - const QJsonObject block = sys.first().toObject(); - EXPECT_EQ(block.value("type").toString(), "text"); - EXPECT_EQ(block.value("text").toString(), "you are a helpful agent"); - EXPECT_EQ(block.value("cache_control").toObject(), expectedEphemeral(false)); -} - -TEST(ClaudeCacheControlTest, EmptySystemStringIsNotWrapped) -{ - QJsonObject request; - request["system"] = ""; - - apply(request, false); - - EXPECT_TRUE(request.value("system").isString()); -} - -TEST(ClaudeCacheControlTest, SystemAsArrayMarksLastBlock) -{ - QJsonObject request; - request["system"] = QJsonArray{ - QJsonObject{{"type", "text"}, {"text", "a"}}, - QJsonObject{{"type", "text"}, {"text", "b"}}}; - - apply(request, false); - - const QJsonArray sys = request.value("system").toArray(); - ASSERT_EQ(sys.size(), 2); - EXPECT_FALSE(sys[0].toObject().contains("cache_control")); - EXPECT_EQ(sys[1].toObject().value("cache_control").toObject(), expectedEphemeral(false)); -} - -TEST(ClaudeCacheControlTest, ToolsLastEntryGetsCacheControl) -{ - QJsonObject request; - request["tools"] = QJsonArray{ - QJsonObject{{"name", "read_file"}}, - QJsonObject{{"name", "edit_file"}}, - QJsonObject{{"name", "search"}}}; - - apply(request, true); - - const QJsonArray tools = request.value("tools").toArray(); - ASSERT_EQ(tools.size(), 3); - EXPECT_FALSE(tools[0].toObject().contains("cache_control")); - EXPECT_FALSE(tools[1].toObject().contains("cache_control")); - EXPECT_EQ(tools[2].toObject().value("cache_control").toObject(), expectedEphemeral(true)); -} - -TEST(ClaudeCacheControlTest, SingleMessageHistorySkipped) -{ - QJsonObject request; - request["messages"] - = QJsonArray{QJsonObject{{"role", "user"}, {"content", "first message"}}}; - - apply(request, false); - - const QJsonArray msgs = request.value("messages").toArray(); - ASSERT_EQ(msgs.size(), 1); - EXPECT_TRUE(msgs[0].toObject().value("content").isString()); -} - -TEST(ClaudeCacheControlTest, HistoryBreakpointOnSecondToLastMessage) -{ - QJsonObject request; - request["messages"] = QJsonArray{ - QJsonObject{{"role", "user"}, {"content", "u1"}}, - QJsonObject{{"role", "assistant"}, {"content", "a1"}}, - QJsonObject{{"role", "user"}, {"content", "u2-current"}}}; - - apply(request, false); - - const QJsonArray msgs = request.value("messages").toArray(); - ASSERT_EQ(msgs.size(), 3); - - EXPECT_TRUE(msgs[0].toObject().value("content").isString()); - - const QJsonArray a1Content = msgs[1].toObject().value("content").toArray(); - ASSERT_EQ(a1Content.size(), 1); - EXPECT_EQ(a1Content.first().toObject().value("text").toString(), "a1"); - EXPECT_EQ( - a1Content.first().toObject().value("cache_control").toObject(), - expectedEphemeral(false)); - - EXPECT_TRUE(msgs[2].toObject().value("content").isString()); -} - -TEST(ClaudeCacheControlTest, HistoryArrayContentMarksLastBlock) -{ - QJsonObject request; - request["messages"] = QJsonArray{ - QJsonObject{ - {"role", "user"}, - {"content", - QJsonArray{ - QJsonObject{{"type", "text"}, {"text", "describe this"}}, - QJsonObject{{"type", "image"}}}}}, - QJsonObject{{"role", "assistant"}, {"content", "ok"}}}; - - apply(request, false); - - const QJsonArray msgs = request.value("messages").toArray(); - const QJsonArray content = msgs[0].toObject().value("content").toArray(); - ASSERT_EQ(content.size(), 2); - EXPECT_FALSE(content[0].toObject().contains("cache_control")); - EXPECT_EQ(content[1].toObject().value("cache_control").toObject(), expectedEphemeral(false)); -} - -TEST(ClaudeCacheControlTest, NoSystemNoToolsNoMessagesIsNoop) -{ - QJsonObject request; - request["model"] = "claude-sonnet-4-5"; - request["max_tokens"] = 1024; - - apply(request, false); - - EXPECT_EQ(request.value("model").toString(), "claude-sonnet-4-5"); - EXPECT_EQ(request.value("max_tokens").toInt(), 1024); - EXPECT_FALSE(request.contains("system")); - EXPECT_FALSE(request.contains("tools")); - EXPECT_FALSE(request.contains("messages")); -} - -TEST(ClaudeCacheControlTest, EmptyToolsArrayIsNoop) -{ - QJsonObject request; - request["tools"] = QJsonArray{}; - - apply(request, false); - - EXPECT_TRUE(request.value("tools").isArray()); - EXPECT_TRUE(request.value("tools").toArray().isEmpty()); -} diff --git a/tests/CodeHandlerTest.cpp b/tests/CodeHandlerTest.cpp deleted file mode 100644 index 9622849..0000000 --- a/tests/CodeHandlerTest.cpp +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright (C) 2025 Povilas Kanapickas -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "completion/CodeHandler.hpp" -#include "TestUtils.hpp" - -#include -#include -#include - -using namespace QodeAssist; - -class CodeHandlerTest : public QObject, public testing::Test -{ - // Tests are written in the fixture format with the expectation that CodeHandler will - // be expanded. - Q_OBJECT -}; - -TEST_F(CodeHandlerTest, testProcessTextEmpty) -{ - EXPECT_EQ(CodeHandler::processText("", "/file.py"), "\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextWithLanguageCodeBlock) -{ - QString input = "This is a comment\n" - "```python\nprint('Hello, world!')\n```\n" - "Another comment"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextWithPlainCodeBlockNoNewline) -{ - QString input = "This is a comment\n" - "```print('Hello, world!')\n```\n" - "Another comment"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextWithPlainCodeBlockWithNewline) -{ - QString input = "This is a comment\n" - "```\nprint('Hello, world!')\n```\n" - "Another comment"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# This is a comment\n\n\nprint('Hello, world!')\n# Another comment\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextNoCommentsWithLanguageCodeBlock) -{ - QString input = "```python\nprint('Hello, world!')\n```"; - - EXPECT_EQ(CodeHandler::processText(input, "/file.py"), "print('Hello, world!')\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextNoCommentsWithPlainCodeBlockNoNewline) -{ - QString input = "```print('Hello, world!')\n```"; - - EXPECT_EQ(CodeHandler::processText(input, "/file.py"), "print('Hello, world!')\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextNoCommentsWithPlainCodeBlockWithNewline) -{ - QString input = "```\nprint('Hello, world!')\n```"; - - EXPECT_EQ(CodeHandler::processText(input, "/file.py"), "\nprint('Hello, world!')\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextWithMultipleCodeBlocksDifferentLanguages) -{ - QString input = "First comment\n```python\nprint('Block 1')\n" - "```\nMiddle comment\n" - "```cpp\ncout << \"Block 2\";\n```\n" - "Last comment"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# First comment\n\n" - "print('Block 1')\n" - "// Middle comment\n\n" - "cout << \"Block 2\";\n" - "// Last comment\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextWithMultipleCodeBlocksSameLanguage) -{ - QString input = "First comment\n```python\nprint('Block 1')\n" - "```\nMiddle comment\n" - "```python\nprint('Block 2')\n```\n" - "Last comment"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# First comment\n\n" - "print('Block 1')\n" - "# Middle comment\n\n" - "print('Block 2')\n" - "# Last comment\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextWithMultiplePlainCodeBlocksWithNewline) -{ - QString input = "First comment\n```\nprint('Block 1')\n" - "```\nMiddle comment\n" - "```\ncout << \"Block 2\";\n```\n" - "Last comment"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# First comment\n\n\n" - "print('Block 1')\n" - "# Middle comment\n\n\n" - "cout << \"Block 2\";\n" - "# Last comment\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextWithMultiplePlainCodeBlocksWithoutNewline) -{ - QString input = "First comment\n```print('Block 1')\n" - "```\nMiddle comment\n" - "```cout << \"Block 2\";\n```\n" - "Last comment"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# First comment\n\n" - "print('Block 1')\n" - "# Middle comment\n\n" - "cout << \"Block 2\";\n" - "# Last comment\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextWithEmptyLines) -{ - QString input = "Comment with empty line\n\n```python\nprint('Hello')\n```\n\nAnother comment"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# Comment with empty line\n\n\n" - "print('Hello')\n\n" - "# Another comment\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextPlainCodeBlockWithNewlineWithEmptyLines) -{ - QString input = "Comment with empty line\n\n```\nprint('Hello')\n```\n\nAnother comment"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# Comment with empty line\n\n\n\n" - "print('Hello')\n\n" - "# Another comment\n\n"); -} - -TEST_F(CodeHandlerTest, testProcessTextWithoutCodeBlock) -{ - QString input = "This is just a comment\nwith multiple lines"; - - EXPECT_EQ( - CodeHandler::processText(input, "/file.py"), - "# This is just a comment\n# with multiple lines\n\n"); -} - -TEST_F(CodeHandlerTest, testDetectLanguageFromLine) -{ - EXPECT_EQ(CodeHandler::detectLanguageFromLine("```python"), "python"); - EXPECT_EQ(CodeHandler::detectLanguageFromLine("```javascript"), "js"); - EXPECT_EQ(CodeHandler::detectLanguageFromLine("```cpp"), "c-like"); - EXPECT_EQ(CodeHandler::detectLanguageFromLine("``` ruby "), "ruby"); - EXPECT_EQ(CodeHandler::detectLanguageFromLine("```"), ""); - EXPECT_EQ(CodeHandler::detectLanguageFromLine("``` "), ""); -} - -TEST_F(CodeHandlerTest, testDetectLanguageFromExtension) -{ - EXPECT_EQ(CodeHandler::detectLanguageFromExtension("py"), "python"); - EXPECT_EQ(CodeHandler::detectLanguageFromExtension("js"), "js"); - EXPECT_EQ(CodeHandler::detectLanguageFromExtension("cpp"), "c-like"); - EXPECT_EQ(CodeHandler::detectLanguageFromExtension("hpp"), "c-like"); - EXPECT_EQ(CodeHandler::detectLanguageFromExtension("rb"), "ruby"); - EXPECT_EQ(CodeHandler::detectLanguageFromExtension("sh"), "shell"); - EXPECT_EQ(CodeHandler::detectLanguageFromExtension("unknown"), ""); - EXPECT_EQ(CodeHandler::detectLanguageFromExtension(""), ""); -} - -TEST_F(CodeHandlerTest, testCommentPrefixForDifferentLanguages) -{ - struct TestCase - { - std::string language; - QString input; - QString expected; - }; - - std::vector testCases - = {{"python", "Comment\n```python\ncode\n```", "# Comment\n\ncode\n"}, - {"cpp", "Comment\n```cpp\ncode\n```", "// Comment\n\ncode\n"}, - {"ruby", "Comment\n```ruby\ncode\n```", "# Comment\n\ncode\n"}, - {"lua", "Comment\n```lua\ncode\n```", "-- Comment\n\ncode\n"}}; - - for (const auto &testCase : testCases) { - EXPECT_EQ(CodeHandler::processText(testCase.input, ""), testCase.expected) - << "Failed for language: " << testCase.language; - } -} - -#include "CodeHandlerTest.moc" diff --git a/tests/DocumentContextReaderTest.cpp b/tests/DocumentContextReaderTest.cpp deleted file mode 100644 index b837a6d..0000000 --- a/tests/DocumentContextReaderTest.cpp +++ /dev/null @@ -1,393 +0,0 @@ -// Copyright (C) 2025 Povilas Kanapickas -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "context/DocumentContextReader.hpp" -#include "TestUtils.hpp" - -#include -#include -#include - -namespace QodeAssist::LLMCore { - -void PrintTo(const ContextData &data, std::ostream *os) -{ - *os << "ContextData{prefix=" - << (data.prefix ? data.prefix->toStdString() : "") - << ", suffix=" << (data.suffix ? data.suffix->toStdString() : "") - << ", fileContext=" << (data.fileContext ? data.fileContext->toStdString() : "") - << "}"; -} - -} // namespace QodeAssist::LLMCore - -using namespace QodeAssist::Context; -using namespace QodeAssist::LLMCore; -using namespace QodeAssist::Settings; - -class DocumentContextReaderTest : public QObject, public testing::Test -{ - Q_OBJECT - -protected: - QTextDocument *createTestDocument(const QString &text) - { - auto *doc = new QTextDocument(this); - doc->setPlainText(text); - return doc; - } - - DocumentContextReader createTestReader(const QString &text) - { - return DocumentContextReader(createTestDocument(text), "text/python", "/path/to/file"); - } - - QSharedPointer createSettingsForWholeFile() - { - // CodeCompletionSettings is noncopyable - auto settings = QSharedPointer::create(); - settings->readFullFile.setValue(true); - return settings; - } - - QSharedPointer createSettingsForLines(int linesBefore, int linesAfter) - { - // CodeCompletionSettings is noncopyable - auto settings = QSharedPointer::create(); - settings->readFullFile.setValue(false); - settings->readStringsBeforeCursor.setValue(linesBefore); - settings->readStringsAfterCursor.setValue(linesAfter); - return settings; - } -}; - -TEST_F(DocumentContextReaderTest, testGetLineText) -{ - auto reader = createTestReader("Line 0\nLine 1\nLine 2"); - - EXPECT_EQ(reader.getLineText(0), "Line 0"); - EXPECT_EQ(reader.getLineText(1), "Line 1"); - EXPECT_EQ(reader.getLineText(2), "Line 2"); - EXPECT_EQ(reader.getLineText(0, 4), "Line"); -} - -TEST_F(DocumentContextReaderTest, testGetContext) -{ - auto reader = createTestReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - // Unknown cursor position - EXPECT_EQ(reader.getContextBefore(0, -1, 2), "Line 0"); - EXPECT_EQ(reader.getContextAfter(0, -1, 2), "Line 0\nLine 1"); - - EXPECT_EQ(reader.getContextBefore(1, -1, 2), "Line 0\nLine 1"); - EXPECT_EQ(reader.getContextAfter(1, -1, 2), "Line 1\nLine 2"); - - EXPECT_EQ(reader.getContextBefore(2, -1, 2), "Line 1\nLine 2"); - EXPECT_EQ(reader.getContextAfter(2, -1, 2), "Line 2\nLine 3"); - - EXPECT_EQ(reader.getContextBefore(3, -1, 2), "Line 2\nLine 3"); - EXPECT_EQ(reader.getContextAfter(3, -1, 2), "Line 3\nLine 4"); - - // Known cursor position - EXPECT_EQ(reader.getContextBefore(0, 1, 2), "L"); - EXPECT_EQ(reader.getContextAfter(0, 1, 2), "ine 0\nLine 1"); - - EXPECT_EQ(reader.getContextBefore(1, 1, 2), "Line 0\nL"); - EXPECT_EQ(reader.getContextAfter(1, 1, 2), "ine 1\nLine 2"); - - EXPECT_EQ(reader.getContextBefore(2, 1, 2), "Line 1\nL"); - EXPECT_EQ(reader.getContextAfter(2, 1, 2), "ine 2\nLine 3"); - - EXPECT_EQ(reader.getContextBefore(3, 1, 2), "Line 2\nL"); - EXPECT_EQ(reader.getContextAfter(3, 1, 2), "ine 3\nLine 4"); -} - -TEST_F(DocumentContextReaderTest, testGetContextWithCopyright) -{ - auto reader = createTestReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3"); - - // Unknown cursor position - EXPECT_EQ(reader.getContextBefore(0, -1, 2), ""); - EXPECT_EQ(reader.getContextAfter(0, -1, 2), "Line 0"); - - EXPECT_EQ(reader.getContextBefore(1, -1, 2), "Line 0"); - EXPECT_EQ(reader.getContextAfter(1, -1, 2), "Line 0\nLine 1"); - - EXPECT_EQ(reader.getContextBefore(2, -1, 2), "Line 0\nLine 1"); - EXPECT_EQ(reader.getContextAfter(2, -1, 2), "Line 1\nLine 2"); - - EXPECT_EQ(reader.getContextBefore(3, -1, 2), "Line 1\nLine 2"); - EXPECT_EQ(reader.getContextAfter(3, -1, 2), "Line 2\nLine 3"); - - // Known cursor position - EXPECT_EQ(reader.getContextBefore(0, 1, 2), ""); - EXPECT_EQ(reader.getContextAfter(0, 1, 2), "Line 0"); - - EXPECT_EQ(reader.getContextBefore(1, 1, 2), "L"); - EXPECT_EQ(reader.getContextAfter(1, 1, 2), "ine 0\nLine 1"); - - EXPECT_EQ(reader.getContextBefore(2, 1, 2), "Line 0\nL"); - EXPECT_EQ(reader.getContextAfter(2, 1, 2), "ine 1\nLine 2"); - - EXPECT_EQ(reader.getContextBefore(3, 1, 2), "Line 1\nL"); - EXPECT_EQ(reader.getContextAfter(3, 1, 2), "ine 2\nLine 3"); -} - -TEST_F(DocumentContextReaderTest, testReadWholeFile) -{ - auto reader = createTestReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - // Unknown cursor position - EXPECT_EQ(reader.readWholeFileBefore(0, -1), "Line 0"); - EXPECT_EQ(reader.readWholeFileAfter(0, -1), "Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - EXPECT_EQ(reader.readWholeFileBefore(1, -1), "Line 0\nLine 1"); - EXPECT_EQ(reader.readWholeFileAfter(1, -1), "Line 1\nLine 2\nLine 3\nLine 4"); - - EXPECT_EQ(reader.readWholeFileBefore(2, -1), "Line 0\nLine 1\nLine 2"); - EXPECT_EQ(reader.readWholeFileAfter(2, -1), "Line 2\nLine 3\nLine 4"); - - EXPECT_EQ(reader.readWholeFileBefore(3, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - EXPECT_EQ(reader.readWholeFileAfter(3, -1), "Line 3\nLine 4"); - - EXPECT_EQ(reader.readWholeFileBefore(4, -1), "Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - EXPECT_EQ(reader.readWholeFileAfter(4, -1), "Line 4"); - - // Known cursor position - EXPECT_EQ(reader.readWholeFileBefore(0, 1), "L"); - EXPECT_EQ(reader.readWholeFileAfter(0, 1), "ine 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - EXPECT_EQ(reader.readWholeFileBefore(1, 1), "Line 0\nL"); - EXPECT_EQ(reader.readWholeFileAfter(1, 1), "ine 1\nLine 2\nLine 3\nLine 4"); - - EXPECT_EQ(reader.readWholeFileBefore(2, 1), "Line 0\nLine 1\nL"); - EXPECT_EQ(reader.readWholeFileAfter(2, 1), "ine 2\nLine 3\nLine 4"); - - EXPECT_EQ(reader.readWholeFileBefore(3, 1), "Line 0\nLine 1\nLine 2\nL"); - EXPECT_EQ(reader.readWholeFileAfter(3, 1), "ine 3\nLine 4"); - - EXPECT_EQ(reader.readWholeFileBefore(4, 1), "Line 0\nLine 1\nLine 2\nLine 3\nL"); - EXPECT_EQ(reader.readWholeFileAfter(4, 1), "ine 4"); -} - -TEST_F(DocumentContextReaderTest, testReadWholeFileWithCopyright) -{ - auto reader = createTestReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3"); - // Unknown cursor position - EXPECT_EQ(reader.readWholeFileBefore(0, -1), ""); - EXPECT_EQ(reader.readWholeFileAfter(0, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(1, -1), "Line 0"); - EXPECT_EQ(reader.readWholeFileAfter(1, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(2, -1), "Line 0\nLine 1"); - EXPECT_EQ(reader.readWholeFileAfter(2, -1), "Line 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(3, -1), "Line 0\nLine 1\nLine 2"); - EXPECT_EQ(reader.readWholeFileAfter(3, -1), "Line 2\nLine 3"); - - // Known cursor position - EXPECT_EQ(reader.readWholeFileBefore(0, 0), ""); - EXPECT_EQ(reader.readWholeFileAfter(0, 0), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(1, 0), ""); - EXPECT_EQ(reader.readWholeFileAfter(1, 0), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(2, 0), "Line 0\n"); - EXPECT_EQ(reader.readWholeFileAfter(2, 0), "Line 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(3, 0), "Line 0\nLine 1\n"); - EXPECT_EQ(reader.readWholeFileAfter(3, 0), "Line 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(0, 1), ""); - EXPECT_EQ(reader.readWholeFileAfter(0, 1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(1, 1), "L"); - EXPECT_EQ(reader.readWholeFileAfter(1, 1), "ine 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(2, 1), "Line 0\nL"); - EXPECT_EQ(reader.readWholeFileAfter(2, 1), "ine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(3, 1), "Line 0\nLine 1\nL"); - EXPECT_EQ(reader.readWholeFileAfter(3, 1), "ine 2\nLine 3"); -} - -TEST_F(DocumentContextReaderTest, testReadWholeFileWithMultilineCopyright) -{ - auto reader = createTestReader( - "/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\n" - "Line 0\nLine 1\nLine 2\nLine 3"); - - // Unknown cursor position - EXPECT_EQ(reader.readWholeFileBefore(0, -1), ""); - EXPECT_EQ(reader.readWholeFileAfter(0, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(1, -1), ""); - EXPECT_EQ(reader.readWholeFileAfter(1, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(2, -1), ""); - EXPECT_EQ(reader.readWholeFileAfter(2, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(3, -1), ""); - EXPECT_EQ(reader.readWholeFileAfter(3, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(4, -1), ""); - EXPECT_EQ(reader.readWholeFileAfter(4, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(5, -1), "Line 0"); - EXPECT_EQ(reader.readWholeFileAfter(5, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(6, -1), "Line 0\nLine 1"); - EXPECT_EQ(reader.readWholeFileAfter(6, -1), "Line 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(7, -1), "Line 0\nLine 1\nLine 2"); - EXPECT_EQ(reader.readWholeFileAfter(7, -1), "Line 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(8, -1), "Line 0\nLine 1\nLine 2\nLine 3"); - EXPECT_EQ(reader.readWholeFileAfter(8, -1), "Line 3"); - - // Known cursor position - EXPECT_EQ(reader.readWholeFileBefore(0, 0), ""); - EXPECT_EQ(reader.readWholeFileAfter(0, 0), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(1, 0), ""); - EXPECT_EQ(reader.readWholeFileAfter(1, 0), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(2, 0), ""); - EXPECT_EQ(reader.readWholeFileAfter(2, 0), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(3, 0), ""); - EXPECT_EQ(reader.readWholeFileAfter(3, 0), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(4, 0), ""); - EXPECT_EQ(reader.readWholeFileAfter(4, 0), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(5, 0), ""); - EXPECT_EQ(reader.readWholeFileAfter(5, 0), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(6, 0), "Line 0\n"); - EXPECT_EQ(reader.readWholeFileAfter(6, 0), "Line 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(7, 0), "Line 0\nLine 1\n"); - EXPECT_EQ(reader.readWholeFileAfter(7, 0), "Line 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(8, 0), "Line 0\nLine 1\nLine 2\n"); - EXPECT_EQ(reader.readWholeFileAfter(8, 0), "Line 3"); - - EXPECT_EQ(reader.readWholeFileBefore(0, 1), ""); - EXPECT_EQ(reader.readWholeFileAfter(0, 1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(1, 1), ""); - EXPECT_EQ(reader.readWholeFileAfter(1, 1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(2, 1), ""); - EXPECT_EQ(reader.readWholeFileAfter(2, 1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(3, 1), ""); - EXPECT_EQ(reader.readWholeFileAfter(3, 1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(4, 1), ""); - EXPECT_EQ(reader.readWholeFileAfter(4, 1), "Line 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(5, 1), "L"); - EXPECT_EQ(reader.readWholeFileAfter(5, 1), "ine 0\nLine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(6, 1), "Line 0\nL"); - EXPECT_EQ(reader.readWholeFileAfter(6, 1), "ine 1\nLine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(7, 1), "Line 0\nLine 1\nL"); - EXPECT_EQ(reader.readWholeFileAfter(7, 1), "ine 2\nLine 3"); - - EXPECT_EQ(reader.readWholeFileBefore(8, 1), "Line 0\nLine 1\nLine 2\nL"); - EXPECT_EQ(reader.readWholeFileAfter(8, 1), "ine 3"); -} - -TEST_F(DocumentContextReaderTest, testFindCopyrightSingleLine) -{ - auto reader = createTestReader("/* Copyright (C) 2024 */\nCode line 0\nCode line 1"); - - auto info = reader.findCopyright(); - ASSERT_TRUE(info.found); - EXPECT_EQ(info.startLine, 0); - EXPECT_EQ(info.endLine, 0); -} - -TEST_F(DocumentContextReaderTest, testFindCopyrightMultiLine) -{ - auto reader = createTestReader( - "/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\nCode line 0"); - - auto info = reader.findCopyright(); - ASSERT_TRUE(info.found); - EXPECT_EQ(info.startLine, 0); - EXPECT_EQ(info.endLine, 4); -} - -TEST_F(DocumentContextReaderTest, testFindCopyrightMultipleBlocks) -{ - auto reader = createTestReader("/* Copyright 2023 */\n\n/* Copyright 2024 */\nCode"); - - auto info = reader.findCopyright(); - ASSERT_TRUE(info.found); - EXPECT_EQ(info.startLine, 0); - EXPECT_EQ(info.endLine, 0); -} - -TEST_F(DocumentContextReaderTest, testFindCopyrightNoCopyright) -{ - auto reader = createTestReader("/* Just a comment */\nCode line 0"); - - auto info = reader.findCopyright(); - ASSERT_TRUE(!info.found); - EXPECT_EQ(info.startLine, -1); - EXPECT_EQ(info.endLine, -1); -} - -TEST_F(DocumentContextReaderTest, testGetContextBetween) -{ - auto reader = createTestReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - EXPECT_EQ(reader.getContextBetween(2, -1, 0, -1), ""); - EXPECT_EQ(reader.getContextBetween(0, -1, 0, -1), "Line 0"); - EXPECT_EQ(reader.getContextBetween(1, -1, 1, -1), "Line 1"); - EXPECT_EQ(reader.getContextBetween(1, 3, 1, 1), ""); - EXPECT_EQ(reader.getContextBetween(1, 3, 1, 3), ""); - EXPECT_EQ(reader.getContextBetween(1, 3, 1, 4), "e"); - - EXPECT_EQ(reader.getContextBetween(1, -1, 3, -1), "Line 1\nLine 2\nLine 3"); - EXPECT_EQ(reader.getContextBetween(1, 2, 3, -1), "ne 1\nLine 2\nLine 3"); - EXPECT_EQ(reader.getContextBetween(1, -1, 3, 2), "Line 1\nLine 2\nLi"); - EXPECT_EQ(reader.getContextBetween(1, 2, 3, 2), "ne 1\nLine 2\nLi"); -} - -TEST_F(DocumentContextReaderTest, testPrepareContext) -{ - auto reader = createTestReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); - - EXPECT_EQ( - reader.prepareContext(2, 3, *createSettingsForWholeFile()), - (QodeAssist::LLMCore::ContextData{ - .prefix = "Line 0\nLine 1\nLin", - .suffix = "e 2\nLine 3\nLine 4", - .fileContext = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n" - "Recent Project Changes Context:\n "})); - - EXPECT_EQ( - reader.prepareContext(2, 3, *createSettingsForLines(1, 1)), - (QodeAssist::LLMCore::ContextData{ - .prefix = "Line 1\nLin", - .suffix = "e 2\nLine 3", - .fileContext = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n" - "Recent Project Changes Context:\n "})); - - EXPECT_EQ( - reader.prepareContext(2, 3, *createSettingsForLines(2, 2)), - (QodeAssist::LLMCore::ContextData{ - .prefix = "Line 0\nLine 1\nLin", - .suffix = "e 2\nLine 3\nLine 4", - .fileContext = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n" - "Recent Project Changes Context:\n "})); -} - -#include "DocumentContextReaderTest.moc" diff --git a/tests/LLMSuggestionTest.cpp b/tests/LLMSuggestionTest.cpp deleted file mode 100644 index e482170..0000000 --- a/tests/LLMSuggestionTest.cpp +++ /dev/null @@ -1,112 +0,0 @@ -// 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 "completion/LLMSuggestion.hpp" -#include "TestUtils.hpp" - -#include -#include -#include - -using namespace QodeAssist; - -class LLMSuggestionTest : public QObject, public testing::Test -{ - Q_OBJECT -}; - -// Degenerate / no-op cases -TEST_F(LLMSuggestionTest, emptyRight) -{ - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("foo", ""), 0); -} - -TEST_F(LLMSuggestionTest, noOverlap) -{ - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("foo", "bar"), 0); -} - -TEST_F(LLMSuggestionTest, singleCharNoOverlap) -{ - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("a", "b"), 0); -} - -// LCP: model echoed the existing right side as its own prefix -TEST_F(LLMSuggestionTest, lcpExtendsRight) -{ - // cursor: int |myVariable ; suggestion echoes "myVar" then adds nothing. - // LCP=5, remainder "iable" not a closing tail -> replace 5. - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("myVar", "myVariable"), 5); -} - -TEST_F(LLMSuggestionTest, lcpPartialEngineCall) -{ - // cursor: |engine.rootContext() ; suggestion: engine.load() - // LCP="engine." (7); remainder "rootContext()" is not closing-only -> keep LCP - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()"), 7); -} - -// LCP + closing-only tail: extend to full right -TEST_F(LLMSuggestionTest, lcpThenClosingTailExtendsFull) -{ - // cursor: QStringList |colors{}; ; suggestion: colors << "red" - // LCP=6 ("colors"); remainder "{};" is punctuation -> replace all (9) - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};"), 9); -} - -// LCP=0, right is closing-only, suggestion ends with a matching closer -> replace full right -TEST_F(LLMSuggestionTest, closingTailReplaceSemicolon) -{ - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("x;", ";"), 1); -} - -TEST_F(LLMSuggestionTest, closingTailReplaceParen) -{ - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")"), 1); -} - -TEST_F(LLMSuggestionTest, closingTailReplaceBrackets) -{ - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("[0]", "];"), 2); -} - -TEST_F(LLMSuggestionTest, closingTailReplaceBracesAndSemi) -{ - // cursor: colors|{}; ; suggestion: = {"red"} - // LCP=0; right "{};" is closing-only; suggestion ends on "}" which is in right -> replace 3 - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};"), 3); -} - -TEST_F(LLMSuggestionTest, closingTailWithLeadingSpace) -{ - EXPECT_EQ(LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};"), 3); -} - -TEST_F(LLMSuggestionTest, closingTailEqualsSemi) -{ - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("= 5;", ";"), 1); -} - -// LCP=0, right is closing-only but suggestion does not end on any of its chars -> leave right alone -TEST_F(LLMSuggestionTest, closingTailNoMatchingClose) -{ - // right "};" closes; suggestion ends on '"' (not in close set) -> 0 - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};"), 0); -} - -// Right side has real code (not closing-only) and no LCP -> leave it alone -TEST_F(LLMSuggestionTest, realCodeRightNoLcp) -{ - // cursor: int |myVar = 5; ; suggestion: myVar - // LCP=0, right " = 5;" is not closing-only (has '5') -> 0 - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("myVar", " = 5;"), 0); -} - -// Trailing whitespace is not treated as a closer, suggestion has no closer -> leave alone -TEST_F(LLMSuggestionTest, trailingWhitespaceOnlyLeftAlone) -{ - EXPECT_EQ(LLMSuggestion::calculateReplaceLength("code", " "), 0); -} - -#include "LLMSuggestionTest.moc" diff --git a/tests/QodeAssistTest.cpp b/tests/QodeAssistTest.cpp new file mode 100644 index 0000000..8b02c24 --- /dev/null +++ b/tests/QodeAssistTest.cpp @@ -0,0 +1,1746 @@ +// Copyright (C) 2026 Petr Mironychev +// Copyright (C) 2025 Povilas Kanapickas +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "QodeAssistTest.hpp" + +#include "ChatView/ChatHistoryBridge.hpp" +#include "ChatView/ChatModel.hpp" +#include "completion/CodeHandler.hpp" +#include "completion/LLMSuggestion.hpp" +#include "llmcore/ContextData.hpp" +#include "providers/ClaudeCacheControl.hpp" +#include "session/HistoryProjection.hpp" +#include "session/HistorySerializer.hpp" +#include "session/Session.hpp" +#include "session/TurnContextBuilder.hpp" + +#include +#include +#include + +namespace QodeAssist { + +namespace { + +constexpr char kTestMimeType[] = "text/python"; +constexpr char kTestFilePath[] = "/path/to/file"; +constexpr char kTestFileContext[] + = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n"; + +Session::Message userTurn() +{ + Session::Message message; + message.role = Session::MessageRole::User; + message.id = "u1"; + message.blocks + = {Session::TextBlock{"explain this"}, + Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}, + Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}; + return message; +} + +Session::Message assistantTurn() +{ + Session::Message message; + message.role = Session::MessageRole::Assistant; + message.id = "r1"; + message.blocks + = {Session::ThinkingBlock{"weighing options", "sig-1", false}, + Session::ToolCallBlock{ + "t1", "read_file", QJsonObject{{"path", "main.cpp"}}, "int main() {}"}, + Session::TextBlock{"here is the answer"}}; + message.usage = Session::Usage{120, 40, 80, 12}; + return message; +} + +Session::ConversationHistory sampleHistory() +{ + Session::Message assistant = assistantTurn(); + assistant.blocks.append( + Session::FileEditBlock{"e1", "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\"}"}); + + Session::ConversationHistory history; + history.append(userTurn()); + history.append(assistant); + return history; +} + +Session::ConversationHistory historyWithoutFileEdits() +{ + Session::ConversationHistory history; + history.append(userTurn()); + history.append(assistantTurn()); + return history; +} + +class FakeChatBackend : public Session::ChatBackend +{ +public: + void sendTurn(const Session::TurnRequest &request) override + { + ++m_turnCount; + m_lastOptions = request.options; + m_lastUserBlocks = request.userBlocks; + m_historySizeAtSend = request.history ? request.history->size() : -1; + m_lastContext = request.context; + } + + void cancel() override { ++m_cancelCount; } + + void script(const QList &events) + { + for (const Session::SessionEvent &scripted : events) + emit sessionEvent(scripted); + } + + int turnCount() const { return m_turnCount; } + int cancelCount() const { return m_cancelCount; } + qsizetype historySizeAtSend() const { return m_historySizeAtSend; } + const QList &lastUserBlocks() const { return m_lastUserBlocks; } + const std::optional &lastContext() const { return m_lastContext; } + Session::TurnOptions lastOptions() const { return m_lastOptions; } + +private: + int m_turnCount = 0; + int m_cancelCount = 0; + qsizetype m_historySizeAtSend = -1; + QList m_lastUserBlocks; + std::optional m_lastContext; + Session::TurnOptions m_lastOptions; +}; + +QList scriptedTurn(const QString &turnId) +{ + return { + Session::TurnStarted{turnId}, + Session::ThinkingReceived{turnId, "weighing options", "sig-1", false}, + Session::TextDelta{turnId, "let me "}, + Session::TextDelta{turnId, "check"}, + Session::ToolCallStarted{turnId, "t1", "read_file", QJsonObject{{"path", "main.cpp"}}, false}, + Session::ToolCallCompleted{turnId, "t1", "read_file", "int main() {}"}, + Session::TextDelta{turnId, "here is the answer"}, + Session::UsageReported{turnId, Session::Usage{120, 40, 80, 12}}, + Session::TurnCompleted{turnId}}; +} + +class FakeProjectContext : public Session::IProjectContextPort +{ +public: + FakeProjectContext(Session::ProjectInfo info, QString rules) + : m_info(std::move(info)) + , m_rules(std::move(rules)) + {} + + Session::ProjectInfo projectInfo() const override { return m_info; } + QString projectRules() const override { return m_rules; } + +private: + Session::ProjectInfo m_info; + QString m_rules; +}; + +class FakeSkillsContext : public Session::ISkillsContextPort +{ +public: + FakeSkillsContext(QString alwaysOn, QString catalog, QList skills) + : m_alwaysOn(std::move(alwaysOn)) + , m_catalog(std::move(catalog)) + , m_skills(std::move(skills)) + {} + + QString alwaysOnBodies() const override { return m_alwaysOn; } + QString catalogText() const override { return m_catalog; } + + std::optional findSkill(const QString &name) const override + { + for (const Session::InvokedSkill &skill : m_skills) { + if (skill.name == name) + return skill; + } + return std::nullopt; + } + +private: + QString m_alwaysOn; + QString m_catalog; + QList m_skills; +}; + +class FakeLinkedFiles : public Session::ILinkedFilesPort +{ +public: + explicit FakeLinkedFiles(QList files) + : m_files(std::move(files)) + {} + + QList readFiles(const QList &paths) const override + { + m_requestedPaths = paths; + return m_files; + } + + QList requestedPaths() const { return m_requestedPaths; } + +private: + QList m_files; + mutable QList m_requestedPaths; +}; + +Session::ProjectInfo sampleProject() +{ + Session::ProjectInfo info; + info.available = true; + info.displayName = "QodeAssist"; + info.sourceRoot = "/src/QodeAssist"; + info.buildDirectory = "/src/QodeAssist/build"; + return info; +} + +} // namespace + +Context::DocumentContextReader QodeAssistTest::createReader(const QString &text) +{ + auto *document = new QTextDocument(this); + document->setPlainText(text); + return Context::DocumentContextReader(document, kTestMimeType, kTestFilePath); +} + +QSharedPointer QodeAssistTest::createSettingsForWholeFile() +{ + auto settings = QSharedPointer::create(); + settings->readFullFile.setValue(true, Utils::BaseAspect::BeQuiet); + settings->useProjectChangesCache.setValue(false, Utils::BaseAspect::BeQuiet); + return settings; +} + +QSharedPointer QodeAssistTest::createSettingsForLines( + int linesBefore, int linesAfter) +{ + auto settings = QSharedPointer::create(); + settings->readFullFile.setValue(false, Utils::BaseAspect::BeQuiet); + settings->readStringsBeforeCursor.setValue(linesBefore, Utils::BaseAspect::BeQuiet); + settings->readStringsAfterCursor.setValue(linesAfter, Utils::BaseAspect::BeQuiet); + settings->useProjectChangesCache.setValue(false, Utils::BaseAspect::BeQuiet); + return settings; +} + +QJsonObject QodeAssistTest::expectedEphemeral(bool extendedTtl) +{ + QJsonObject cacheControl{{"type", "ephemeral"}}; + if (extendedTtl) + cacheControl["ttl"] = "1h"; + return cacheControl; +} + +void QodeAssistTest::testProcessTextEmpty() +{ + QCOMPARE(CodeHandler::processText("", "/file.py"), QString("\n\n")); +} + +void QodeAssistTest::testProcessTextCommentsAroundCodeBlock() +{ + const QString input = "This is a comment\n" + "```python\nprint('Hello, world!')\n```\n" + "Another comment"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString("# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n")); +} + +void QodeAssistTest::testProcessTextWithPlainCodeBlockNoNewline() +{ + const QString input = "This is a comment\n" + "```print('Hello, world!')\n```\n" + "Another comment"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString("# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n")); +} + +void QodeAssistTest::testProcessTextWithPlainCodeBlockWithNewline() +{ + const QString input = "This is a comment\n" + "```\nprint('Hello, world!')\n```\n" + "Another comment"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString("# This is a comment\n\n\nprint('Hello, world!')\n# Another comment\n\n")); +} + +void QodeAssistTest::testProcessTextNoCommentsWithLanguageCodeBlock() +{ + const QString input = "```python\nprint('Hello, world!')\n```"; + + QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("print('Hello, world!')\n")); +} + +void QodeAssistTest::testProcessTextNoCommentsWithPlainCodeBlockNoNewline() +{ + const QString input = "```print('Hello, world!')\n```"; + + QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("print('Hello, world!')\n")); +} + +void QodeAssistTest::testProcessTextNoCommentsWithPlainCodeBlockWithNewline() +{ + const QString input = "```\nprint('Hello, world!')\n```"; + + QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("\nprint('Hello, world!')\n")); +} + +void QodeAssistTest::testProcessTextWithMultipleCodeBlocksDifferentLanguages() +{ + const QString input = "First comment\n```python\nprint('Block 1')\n" + "```\nMiddle comment\n" + "```cpp\ncout << \"Block 2\";\n```\n" + "Last comment"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString( + "# First comment\n\n" + "print('Block 1')\n" + "// Middle comment\n\n" + "cout << \"Block 2\";\n" + "// Last comment\n\n")); +} + +void QodeAssistTest::testProcessTextWithMultipleCodeBlocksSameLanguage() +{ + const QString input = "First comment\n```python\nprint('Block 1')\n" + "```\nMiddle comment\n" + "```python\nprint('Block 2')\n```\n" + "Last comment"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString( + "# First comment\n\n" + "print('Block 1')\n" + "# Middle comment\n\n" + "print('Block 2')\n" + "# Last comment\n\n")); +} + +void QodeAssistTest::testProcessTextWithMultiplePlainCodeBlocksWithNewline() +{ + const QString input = "First comment\n```\nprint('Block 1')\n" + "```\nMiddle comment\n" + "```\ncout << \"Block 2\";\n```\n" + "Last comment"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString( + "# First comment\n\n\n" + "print('Block 1')\n" + "# Middle comment\n\n\n" + "cout << \"Block 2\";\n" + "# Last comment\n\n")); +} + +void QodeAssistTest::testProcessTextWithMultiplePlainCodeBlocksWithoutNewline() +{ + const QString input = "First comment\n```print('Block 1')\n" + "```\nMiddle comment\n" + "```cout << \"Block 2\";\n```\n" + "Last comment"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString( + "# First comment\n\n" + "print('Block 1')\n" + "# Middle comment\n\n" + "cout << \"Block 2\";\n" + "# Last comment\n\n")); +} + +void QodeAssistTest::testProcessTextWithEmptyLines() +{ + const QString input + = "Comment with empty line\n\n```python\nprint('Hello')\n```\n\nAnother comment"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString( + "# Comment with empty line\n\n\n" + "print('Hello')\n\n" + "# Another comment\n\n")); +} + +void QodeAssistTest::testProcessTextPlainCodeBlockWithNewlineWithEmptyLines() +{ + const QString input = "Comment with empty line\n\n```\nprint('Hello')\n```\n\nAnother comment"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString( + "# Comment with empty line\n\n\n\n" + "print('Hello')\n\n" + "# Another comment\n\n")); +} + +void QodeAssistTest::testProcessTextWithoutCodeBlock() +{ + const QString input = "This is just a comment\nwith multiple lines"; + + QCOMPARE( + CodeHandler::processText(input, "/file.py"), + QString("# This is just a comment\n# with multiple lines\n\n")); +} + +void QodeAssistTest::testDetectLanguageFromLine() +{ + QCOMPARE(CodeHandler::detectLanguageFromLine("```python"), QString("python")); + QCOMPARE(CodeHandler::detectLanguageFromLine("```javascript"), QString("js")); + QCOMPARE(CodeHandler::detectLanguageFromLine("```cpp"), QString("c-like")); + QCOMPARE(CodeHandler::detectLanguageFromLine("``` ruby "), QString("ruby")); + QCOMPARE(CodeHandler::detectLanguageFromLine("```"), QString()); + QCOMPARE(CodeHandler::detectLanguageFromLine("``` "), QString()); +} + +void QodeAssistTest::testDetectLanguageFromExtension() +{ + QCOMPARE(CodeHandler::detectLanguageFromExtension("py"), QString("python")); + QCOMPARE(CodeHandler::detectLanguageFromExtension("js"), QString("js")); + QCOMPARE(CodeHandler::detectLanguageFromExtension("cpp"), QString("c-like")); + QCOMPARE(CodeHandler::detectLanguageFromExtension("hpp"), QString("c-like")); + QCOMPARE(CodeHandler::detectLanguageFromExtension("rb"), QString("ruby")); + QCOMPARE(CodeHandler::detectLanguageFromExtension("sh"), QString("shell")); + QCOMPARE(CodeHandler::detectLanguageFromExtension("unknown"), QString()); + QCOMPARE(CodeHandler::detectLanguageFromExtension(""), QString()); +} + +void QodeAssistTest::testCommentPrefixForDifferentLanguages() +{ + struct Case + { + QString input; + QString expected; + }; + + const QList cases{ + {"Comment\n```python\ncode\n```", "# Comment\n\ncode\n"}, + {"Comment\n```cpp\ncode\n```", "// Comment\n\ncode\n"}, + {"Comment\n```ruby\ncode\n```", "# Comment\n\ncode\n"}, + {"Comment\n```lua\ncode\n```", "-- Comment\n\ncode\n"}}; + + for (const auto &testCase : cases) + QCOMPARE(CodeHandler::processText(testCase.input, ""), testCase.expected); +} + +void QodeAssistTest::testHasCodeBlocks() +{ + QVERIFY(CodeHandler::hasCodeBlocks("text\n```python\ncode\n```")); + QVERIFY(!CodeHandler::hasCodeBlocks("just a plain comment\nwith two lines")); +} + +void QodeAssistTest::testReplaceLengthEmptyRight() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", ""), 0); +} + +void QodeAssistTest::testReplaceLengthNoOverlap() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", "bar"), 0); +} + +void QodeAssistTest::testReplaceLengthSingleCharNoOverlap() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("a", "b"), 0); +} + +void QodeAssistTest::testReplaceLengthLcpExtendsRight() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", "myVariable"), 5); +} + +void QodeAssistTest::testReplaceLengthLcpPartialEngineCall() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()"), 7); +} + +void QodeAssistTest::testReplaceLengthLcpThenClosingTailExtendsFull() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};"), 9); +} + +void QodeAssistTest::testReplaceLengthClosingTailReplaceSemicolon() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("x;", ";"), 1); +} + +void QodeAssistTest::testReplaceLengthClosingTailReplaceParen() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")"), 1); +} + +void QodeAssistTest::testReplaceLengthClosingTailReplaceBrackets() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("[0]", "];"), 2); +} + +void QodeAssistTest::testReplaceLengthClosingTailReplaceBracesAndSemi() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};"), 3); +} + +void QodeAssistTest::testReplaceLengthClosingTailWithLeadingSpace() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};"), 3); +} + +void QodeAssistTest::testReplaceLengthClosingTailEqualsSemi() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("= 5;", ";"), 1); +} + +void QodeAssistTest::testReplaceLengthClosingTailNoMatchingClose() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};"), 0); +} + +void QodeAssistTest::testReplaceLengthRealCodeRightNoLcp() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", " = 5;"), 0); +} + +void QodeAssistTest::testReplaceLengthTrailingWhitespaceOnlyLeftAlone() +{ + QCOMPARE(LLMSuggestion::calculateReplaceLength("code", " "), 0); +} + +void QodeAssistTest::testCacheControlBreakpointWithoutExtendedTtl() +{ + const QJsonObject cacheControl = Providers::ClaudeCacheControl::buildBreakpoint(false); + + QCOMPARE(cacheControl.value("type").toString(), QString("ephemeral")); + QVERIFY(!cacheControl.contains("ttl")); +} + +void QodeAssistTest::testCacheControlBreakpointWithExtendedTtl() +{ + const QJsonObject cacheControl = Providers::ClaudeCacheControl::buildBreakpoint(true); + + QCOMPARE(cacheControl.value("type").toString(), QString("ephemeral")); + QCOMPARE(cacheControl.value("ttl").toString(), QString("1h")); +} + +void QodeAssistTest::testCacheControlSystemAsStringWrappedIntoArray() +{ + QJsonObject request; + request["system"] = "you are a helpful agent"; + + Providers::ClaudeCacheControl::apply(request, false); + + QVERIFY(request.value("system").isArray()); + const QJsonArray system = request.value("system").toArray(); + QCOMPARE(system.size(), 1); + + const QJsonObject block = system.first().toObject(); + QCOMPARE(block.value("type").toString(), QString("text")); + QCOMPARE(block.value("text").toString(), QString("you are a helpful agent")); + QCOMPARE(block.value("cache_control").toObject(), expectedEphemeral(false)); +} + +void QodeAssistTest::testCacheControlEmptySystemStringIsNotWrapped() +{ + QJsonObject request; + request["system"] = ""; + + Providers::ClaudeCacheControl::apply(request, false); + + QVERIFY(request.value("system").isString()); +} + +void QodeAssistTest::testCacheControlSystemAsArrayMarksLastBlock() +{ + QJsonObject request; + request["system"] = QJsonArray{ + QJsonObject{{"type", "text"}, {"text", "a"}}, QJsonObject{{"type", "text"}, {"text", "b"}}}; + + Providers::ClaudeCacheControl::apply(request, false); + + const QJsonArray system = request.value("system").toArray(); + QCOMPARE(system.size(), 2); + QVERIFY(!system[0].toObject().contains("cache_control")); + QCOMPARE(system[1].toObject().value("cache_control").toObject(), expectedEphemeral(false)); +} + +void QodeAssistTest::testCacheControlToolsLastEntryGetsCacheControl() +{ + QJsonObject request; + request["tools"] = QJsonArray{ + QJsonObject{{"name", "read_file"}}, + QJsonObject{{"name", "edit_file"}}, + QJsonObject{{"name", "search"}}}; + + Providers::ClaudeCacheControl::apply(request, true); + + const QJsonArray tools = request.value("tools").toArray(); + QCOMPARE(tools.size(), 3); + QVERIFY(!tools[0].toObject().contains("cache_control")); + QVERIFY(!tools[1].toObject().contains("cache_control")); + QCOMPARE(tools[2].toObject().value("cache_control").toObject(), expectedEphemeral(true)); +} + +void QodeAssistTest::testCacheControlSingleMessageHistorySkipped() +{ + QJsonObject request; + request["messages"] = QJsonArray{QJsonObject{{"role", "user"}, {"content", "first message"}}}; + + Providers::ClaudeCacheControl::apply(request, false); + + const QJsonArray messages = request.value("messages").toArray(); + QCOMPARE(messages.size(), 1); + QVERIFY(messages[0].toObject().value("content").isString()); +} + +void QodeAssistTest::testCacheControlHistoryBreakpointOnSecondToLastMessage() +{ + QJsonObject request; + request["messages"] = QJsonArray{ + QJsonObject{{"role", "user"}, {"content", "u1"}}, + QJsonObject{{"role", "assistant"}, {"content", "a1"}}, + QJsonObject{{"role", "user"}, {"content", "u2-current"}}}; + + Providers::ClaudeCacheControl::apply(request, false); + + const QJsonArray messages = request.value("messages").toArray(); + QCOMPARE(messages.size(), 3); + QVERIFY(messages[0].toObject().value("content").isString()); + + const QJsonArray assistantContent = messages[1].toObject().value("content").toArray(); + QCOMPARE(assistantContent.size(), 1); + QCOMPARE(assistantContent.first().toObject().value("text").toString(), QString("a1")); + QCOMPARE( + assistantContent.first().toObject().value("cache_control").toObject(), + expectedEphemeral(false)); + + QVERIFY(messages[2].toObject().value("content").isString()); +} + +void QodeAssistTest::testCacheControlHistoryArrayContentMarksLastBlock() +{ + QJsonObject request; + request["messages"] = QJsonArray{ + QJsonObject{ + {"role", "user"}, + {"content", + QJsonArray{ + QJsonObject{{"type", "text"}, {"text", "describe this"}}, + QJsonObject{{"type", "image"}}}}}, + QJsonObject{{"role", "assistant"}, {"content", "ok"}}}; + + Providers::ClaudeCacheControl::apply(request, false); + + const QJsonArray messages = request.value("messages").toArray(); + const QJsonArray content = messages[0].toObject().value("content").toArray(); + QCOMPARE(content.size(), 2); + QVERIFY(!content[0].toObject().contains("cache_control")); + QCOMPARE(content[1].toObject().value("cache_control").toObject(), expectedEphemeral(false)); +} + +void QodeAssistTest::testCacheControlNoSystemNoToolsNoMessagesIsNoop() +{ + QJsonObject request; + request["model"] = "claude-sonnet-4-5"; + request["max_tokens"] = 1024; + + Providers::ClaudeCacheControl::apply(request, false); + + QCOMPARE(request.value("model").toString(), QString("claude-sonnet-4-5")); + QCOMPARE(request.value("max_tokens").toInt(), 1024); + QVERIFY(!request.contains("system")); + QVERIFY(!request.contains("tools")); + QVERIFY(!request.contains("messages")); +} + +void QodeAssistTest::testCacheControlEmptyToolsArrayIsNoop() +{ + QJsonObject request; + request["tools"] = QJsonArray{}; + + Providers::ClaudeCacheControl::apply(request, false); + + QVERIFY(request.value("tools").isArray()); + QVERIFY(request.value("tools").toArray().isEmpty()); +} + +void QodeAssistTest::testGetLineText() +{ + auto reader = createReader("Line 0\nLine 1\nLine 2"); + + QCOMPARE(reader.getLineText(0), QString("Line 0")); + QCOMPARE(reader.getLineText(1), QString("Line 1")); + QCOMPARE(reader.getLineText(2), QString("Line 2")); + QCOMPARE(reader.getLineText(0, 4), QString("Line")); +} + +void QodeAssistTest::testGetContext() +{ + auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); + + QCOMPARE(reader.getContextBefore(0, -1, 2), QString("Line 0")); + QCOMPARE(reader.getContextAfter(0, -1, 2), QString("Line 0\nLine 1")); + + QCOMPARE(reader.getContextBefore(1, -1, 2), QString("Line 0\nLine 1")); + QCOMPARE(reader.getContextAfter(1, -1, 2), QString("Line 1\nLine 2")); + + QCOMPARE(reader.getContextBefore(2, -1, 2), QString("Line 1\nLine 2")); + QCOMPARE(reader.getContextAfter(2, -1, 2), QString("Line 2\nLine 3")); + + QCOMPARE(reader.getContextBefore(3, -1, 2), QString("Line 2\nLine 3")); + QCOMPARE(reader.getContextAfter(3, -1, 2), QString("Line 3\nLine 4")); + + QCOMPARE(reader.getContextBefore(0, 1, 2), QString("L")); + QCOMPARE(reader.getContextAfter(0, 1, 2), QString("ine 0\nLine 1")); + + QCOMPARE(reader.getContextBefore(1, 1, 2), QString("Line 0\nL")); + QCOMPARE(reader.getContextAfter(1, 1, 2), QString("ine 1\nLine 2")); + + QCOMPARE(reader.getContextBefore(2, 1, 2), QString("Line 1\nL")); + QCOMPARE(reader.getContextAfter(2, 1, 2), QString("ine 2\nLine 3")); + + QCOMPARE(reader.getContextBefore(3, 1, 2), QString("Line 2\nL")); + QCOMPARE(reader.getContextAfter(3, 1, 2), QString("ine 3\nLine 4")); +} + +void QodeAssistTest::testGetContextWithCopyright() +{ + auto reader = createReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3"); + + QCOMPARE(reader.getContextBefore(0, -1, 2), QString()); + QCOMPARE(reader.getContextAfter(0, -1, 2), QString("Line 0")); + + QCOMPARE(reader.getContextBefore(1, -1, 2), QString("Line 0")); + QCOMPARE(reader.getContextAfter(1, -1, 2), QString("Line 0\nLine 1")); + + QCOMPARE(reader.getContextBefore(2, -1, 2), QString("Line 0\nLine 1")); + QCOMPARE(reader.getContextAfter(2, -1, 2), QString("Line 1\nLine 2")); + + QCOMPARE(reader.getContextBefore(3, -1, 2), QString("Line 1\nLine 2")); + QCOMPARE(reader.getContextAfter(3, -1, 2), QString("Line 2\nLine 3")); + + QCOMPARE(reader.getContextBefore(0, 1, 2), QString()); + QCOMPARE(reader.getContextAfter(0, 1, 2), QString("Line 0")); + + QCOMPARE(reader.getContextBefore(1, 1, 2), QString("L")); + QCOMPARE(reader.getContextAfter(1, 1, 2), QString("ine 0\nLine 1")); + + QCOMPARE(reader.getContextBefore(2, 1, 2), QString("Line 0\nL")); + QCOMPARE(reader.getContextAfter(2, 1, 2), QString("ine 1\nLine 2")); + + QCOMPARE(reader.getContextBefore(3, 1, 2), QString("Line 1\nL")); + QCOMPARE(reader.getContextAfter(3, 1, 2), QString("ine 2\nLine 3")); +} + +void QodeAssistTest::testReadWholeFile() +{ + auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); + + QCOMPARE(reader.readWholeFileBefore(0, -1), QString("Line 0")); + QCOMPARE(reader.readWholeFileAfter(0, -1), QString("Line 0\nLine 1\nLine 2\nLine 3\nLine 4")); + + QCOMPARE(reader.readWholeFileBefore(1, -1), QString("Line 0\nLine 1")); + QCOMPARE(reader.readWholeFileAfter(1, -1), QString("Line 1\nLine 2\nLine 3\nLine 4")); + + QCOMPARE(reader.readWholeFileBefore(2, -1), QString("Line 0\nLine 1\nLine 2")); + QCOMPARE(reader.readWholeFileAfter(2, -1), QString("Line 2\nLine 3\nLine 4")); + + QCOMPARE(reader.readWholeFileBefore(3, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + QCOMPARE(reader.readWholeFileAfter(3, -1), QString("Line 3\nLine 4")); + + QCOMPARE(reader.readWholeFileBefore(4, -1), QString("Line 0\nLine 1\nLine 2\nLine 3\nLine 4")); + QCOMPARE(reader.readWholeFileAfter(4, -1), QString("Line 4")); + + QCOMPARE(reader.readWholeFileBefore(0, 1), QString("L")); + QCOMPARE(reader.readWholeFileAfter(0, 1), QString("ine 0\nLine 1\nLine 2\nLine 3\nLine 4")); + + QCOMPARE(reader.readWholeFileBefore(1, 1), QString("Line 0\nL")); + QCOMPARE(reader.readWholeFileAfter(1, 1), QString("ine 1\nLine 2\nLine 3\nLine 4")); + + QCOMPARE(reader.readWholeFileBefore(2, 1), QString("Line 0\nLine 1\nL")); + QCOMPARE(reader.readWholeFileAfter(2, 1), QString("ine 2\nLine 3\nLine 4")); + + QCOMPARE(reader.readWholeFileBefore(3, 1), QString("Line 0\nLine 1\nLine 2\nL")); + QCOMPARE(reader.readWholeFileAfter(3, 1), QString("ine 3\nLine 4")); + + QCOMPARE(reader.readWholeFileBefore(4, 1), QString("Line 0\nLine 1\nLine 2\nLine 3\nL")); + QCOMPARE(reader.readWholeFileAfter(4, 1), QString("ine 4")); +} + +void QodeAssistTest::testReadWholeFileWithCopyright() +{ + auto reader = createReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3"); + + QCOMPARE(reader.readWholeFileBefore(0, -1), QString()); + QCOMPARE(reader.readWholeFileAfter(0, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(1, -1), QString("Line 0")); + QCOMPARE(reader.readWholeFileAfter(1, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(2, -1), QString("Line 0\nLine 1")); + QCOMPARE(reader.readWholeFileAfter(2, -1), QString("Line 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(3, -1), QString("Line 0\nLine 1\nLine 2")); + QCOMPARE(reader.readWholeFileAfter(3, -1), QString("Line 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(0, 0), QString()); + QCOMPARE(reader.readWholeFileAfter(0, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(1, 0), QString()); + QCOMPARE(reader.readWholeFileAfter(1, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(2, 0), QString("Line 0\n")); + QCOMPARE(reader.readWholeFileAfter(2, 0), QString("Line 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(3, 0), QString("Line 0\nLine 1\n")); + QCOMPARE(reader.readWholeFileAfter(3, 0), QString("Line 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(0, 1), QString()); + QCOMPARE(reader.readWholeFileAfter(0, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(1, 1), QString("L")); + QCOMPARE(reader.readWholeFileAfter(1, 1), QString("ine 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(2, 1), QString("Line 0\nL")); + QCOMPARE(reader.readWholeFileAfter(2, 1), QString("ine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(3, 1), QString("Line 0\nLine 1\nL")); + QCOMPARE(reader.readWholeFileAfter(3, 1), QString("ine 2\nLine 3")); +} + +void QodeAssistTest::testReadWholeFileWithMultilineCopyright() +{ + auto reader = createReader( + "/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\n" + "Line 0\nLine 1\nLine 2\nLine 3"); + + QCOMPARE(reader.readWholeFileBefore(0, -1), QString()); + QCOMPARE(reader.readWholeFileAfter(0, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(1, -1), QString()); + QCOMPARE(reader.readWholeFileAfter(1, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(2, -1), QString()); + QCOMPARE(reader.readWholeFileAfter(2, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(3, -1), QString()); + QCOMPARE(reader.readWholeFileAfter(3, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(4, -1), QString()); + QCOMPARE(reader.readWholeFileAfter(4, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(5, -1), QString("Line 0")); + QCOMPARE(reader.readWholeFileAfter(5, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(6, -1), QString("Line 0\nLine 1")); + QCOMPARE(reader.readWholeFileAfter(6, -1), QString("Line 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(7, -1), QString("Line 0\nLine 1\nLine 2")); + QCOMPARE(reader.readWholeFileAfter(7, -1), QString("Line 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(8, -1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + QCOMPARE(reader.readWholeFileAfter(8, -1), QString("Line 3")); + + QCOMPARE(reader.readWholeFileBefore(0, 0), QString()); + QCOMPARE(reader.readWholeFileAfter(0, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(1, 0), QString()); + QCOMPARE(reader.readWholeFileAfter(1, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(2, 0), QString()); + QCOMPARE(reader.readWholeFileAfter(2, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(3, 0), QString()); + QCOMPARE(reader.readWholeFileAfter(3, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(4, 0), QString()); + QCOMPARE(reader.readWholeFileAfter(4, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(5, 0), QString()); + QCOMPARE(reader.readWholeFileAfter(5, 0), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(6, 0), QString("Line 0\n")); + QCOMPARE(reader.readWholeFileAfter(6, 0), QString("Line 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(7, 0), QString("Line 0\nLine 1\n")); + QCOMPARE(reader.readWholeFileAfter(7, 0), QString("Line 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(8, 0), QString("Line 0\nLine 1\nLine 2\n")); + QCOMPARE(reader.readWholeFileAfter(8, 0), QString("Line 3")); + + QCOMPARE(reader.readWholeFileBefore(0, 1), QString()); + QCOMPARE(reader.readWholeFileAfter(0, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(1, 1), QString()); + QCOMPARE(reader.readWholeFileAfter(1, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(2, 1), QString()); + QCOMPARE(reader.readWholeFileAfter(2, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(3, 1), QString()); + QCOMPARE(reader.readWholeFileAfter(3, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(4, 1), QString()); + QCOMPARE(reader.readWholeFileAfter(4, 1), QString("Line 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(5, 1), QString("L")); + QCOMPARE(reader.readWholeFileAfter(5, 1), QString("ine 0\nLine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(6, 1), QString("Line 0\nL")); + QCOMPARE(reader.readWholeFileAfter(6, 1), QString("ine 1\nLine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(7, 1), QString("Line 0\nLine 1\nL")); + QCOMPARE(reader.readWholeFileAfter(7, 1), QString("ine 2\nLine 3")); + + QCOMPARE(reader.readWholeFileBefore(8, 1), QString("Line 0\nLine 1\nLine 2\nL")); + QCOMPARE(reader.readWholeFileAfter(8, 1), QString("ine 3")); +} + +void QodeAssistTest::testFindCopyrightSingleLine() +{ + auto reader = createReader("/* Copyright (C) 2024 */\nCode line 0\nCode line 1"); + + const auto info = reader.findCopyright(); + QVERIFY(info.found); + QCOMPARE(info.startLine, 0); + QCOMPARE(info.endLine, 0); +} + +void QodeAssistTest::testFindCopyrightMultiLine() +{ + auto reader = createReader( + "/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\nCode line 0"); + + const auto info = reader.findCopyright(); + QVERIFY(info.found); + QCOMPARE(info.startLine, 0); + QCOMPARE(info.endLine, 4); +} + +void QodeAssistTest::testFindCopyrightMultipleBlocks() +{ + auto reader = createReader("/* Copyright 2023 */\n\n/* Copyright 2024 */\nCode"); + + const auto info = reader.findCopyright(); + QVERIFY(info.found); + QCOMPARE(info.startLine, 0); + QCOMPARE(info.endLine, 0); +} + +void QodeAssistTest::testFindCopyrightNoCopyright() +{ + auto reader = createReader("/* Just a comment */\nCode line 0"); + + const auto info = reader.findCopyright(); + QVERIFY(!info.found); + QCOMPARE(info.startLine, -1); + QCOMPARE(info.endLine, -1); +} + +void QodeAssistTest::testGetContextBetween() +{ + auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); + + QCOMPARE(reader.getContextBetween(2, -1, 0, -1), QString()); + QCOMPARE(reader.getContextBetween(0, -1, 0, -1), QString("Line 0")); + QCOMPARE(reader.getContextBetween(1, -1, 1, -1), QString("Line 1")); + QCOMPARE(reader.getContextBetween(1, 3, 1, 1), QString()); + QCOMPARE(reader.getContextBetween(1, 3, 1, 3), QString()); + QCOMPARE(reader.getContextBetween(1, 3, 1, 4), QString("e")); + + QCOMPARE(reader.getContextBetween(1, -1, 3, -1), QString("Line 1\nLine 2\nLine 3")); + QCOMPARE(reader.getContextBetween(1, 2, 3, -1), QString("ne 1\nLine 2\nLine 3")); + QCOMPARE(reader.getContextBetween(1, -1, 3, 2), QString("Line 1\nLine 2\nLi")); + QCOMPARE(reader.getContextBetween(1, 2, 3, 2), QString("ne 1\nLine 2\nLi")); +} + +void QodeAssistTest::testPrepareContext() +{ + auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"); + + QCOMPARE( + reader.prepareContext(2, 3, *createSettingsForWholeFile()), + (LLMCore::ContextData{ + .prefix = "Line 0\nLine 1\nLin", + .suffix = "e 2\nLine 3\nLine 4", + .fileContext = kTestFileContext})); + + QCOMPARE( + reader.prepareContext(2, 3, *createSettingsForLines(1, 1)), + (LLMCore::ContextData{ + .prefix = "Line 1\nLin", .suffix = "e 2\nLine 3", .fileContext = kTestFileContext})); + + QCOMPARE( + reader.prepareContext(2, 3, *createSettingsForLines(2, 2)), + (LLMCore::ContextData{ + .prefix = "Line 0\nLine 1\nLin", + .suffix = "e 2\nLine 3\nLine 4", + .fileContext = kTestFileContext})); +} + +void QodeAssistTest::testHistorySnapshotUsesCurrentVersion() +{ + const QJsonObject root = Session::HistorySerializer::toJson(sampleHistory()); + + QCOMPARE(root.value("version").toString(), Session::HistorySerializer::currentVersion()); + QCOMPARE(root.value("messages").toArray().size(), 2); +} + +void QodeAssistTest::testHistorySnapshotRoundTrip() +{ + const Session::ConversationHistory history = sampleHistory(); + + const auto restored = Session::HistorySerializer::fromJson( + Session::HistorySerializer::toJson(history)); + + QVERIFY(restored.has_value()); + QCOMPARE(restored->size(), history.size()); + QCOMPARE(restored->at(1).blocks.size(), history.at(1).blocks.size()); + QCOMPARE(*restored, history); +} + +void QodeAssistTest::testUnsupportedChatVersionIsRejected() +{ + QJsonObject root; + root["version"] = "9.9"; + root["messages"] = QJsonArray{}; + + QVERIFY(!Session::HistorySerializer::fromJson(root).has_value()); + QVERIFY(!Session::HistorySerializer::isSupportedVersion("9.9")); + QVERIFY(Session::HistorySerializer::isSupportedVersion("0.1")); + QVERIFY(Session::HistorySerializer::isSupportedVersion("0.2")); +} + +void QodeAssistTest::testChatFileWithoutMessagesArrayIsRejected() +{ + QJsonObject missing; + missing["version"] = Session::HistorySerializer::currentVersion(); + QVERIFY(!Session::HistorySerializer::fromJson(missing).has_value()); + + QJsonObject wrongType; + wrongType["version"] = Session::HistorySerializer::currentVersion(); + wrongType["messages"] = "not an array"; + QVERIFY(!Session::HistorySerializer::fromJson(wrongType).has_value()); + + QJsonObject empty; + empty["version"] = Session::HistorySerializer::currentVersion(); + empty["messages"] = QJsonArray{}; + const auto history = Session::HistorySerializer::fromJson(empty); + QVERIFY(history.has_value()); + QCOMPARE(history->size(), 0); +} + +void QodeAssistTest::testLegacyChatFileConvertsToHistory() +{ + QJsonObject root; + root["version"] = "0.2"; + root["messages"] = QJsonArray{ + QJsonObject{{"role", 1}, {"content", "explain this"}, {"id", "u1"}}, + QJsonObject{ + {"role", 5}, + {"content", "weighing options\n[Signature: sig-1...]"}, + {"id", "r1"}, + {"signature", "sig-1"}, + {"usage", QJsonObject{{"promptTokens", 120}, {"completionTokens", 40}}}}, + QJsonObject{ + {"role", 3}, + {"content", "read_file\nint main() {}"}, + {"id", "t1"}, + {"toolName", "read_file"}, + {"toolResult", "int main() {}"}}, + QJsonObject{{"role", 2}, {"content", "here is the answer"}, {"id", "r1"}}}; + + const auto history = Session::HistorySerializer::fromJson(root); + + QVERIFY(history.has_value()); + QCOMPARE(history->size(), 2); + + const Session::Message &user = history->at(0); + QCOMPARE(user.role, Session::MessageRole::User); + QCOMPARE(user.blocks.size(), 1); + QCOMPARE(std::get(user.blocks.at(0)), (Session::TextBlock{"explain this"})); + + const Session::Message &assistant = history->at(1); + QCOMPARE(assistant.role, Session::MessageRole::Assistant); + QCOMPARE(assistant.id, QString("r1")); + QCOMPARE(assistant.blocks.size(), 3); + QCOMPARE( + std::get(assistant.blocks.at(0)), + (Session::ThinkingBlock{"weighing options", "sig-1", false})); + QCOMPARE( + std::get(assistant.blocks.at(1)), + (Session::ToolCallBlock{"t1", "read_file", QJsonObject{}, "int main() {}"})); + QCOMPARE( + std::get(assistant.blocks.at(2)), + (Session::TextBlock{"here is the answer"})); + QCOMPARE(assistant.usage.promptTokens, 120); + QCOMPARE(assistant.usage.completionTokens, 40); +} + +void QodeAssistTest::testLegacyToolRowWithoutMetadataKeepsItsText() +{ + QJsonObject root; + root["version"] = "0.2"; + root["messages"] = QJsonArray{ + QJsonObject{{"role", 1}, {"content", "list the files"}, {"id", "u1"}}, + QJsonObject{{"role", 3}, {"content", "main.cpp\nmain.qml"}, {"id", "t1"}}}; + + const auto history = Session::HistorySerializer::fromJson(root); + QVERIFY(history.has_value()); + + const QList rows = Session::projectToRows(*history); + QCOMPARE(rows.size(), 2); + QCOMPARE(rows.at(1).kind, Session::RowKind::Tool); + QCOMPARE(rows.at(1).id, QString("t1")); + QCOMPARE(rows.at(1).content, QString("main.cpp\nmain.qml")); +} + +void QodeAssistTest::testChatRowsRoundTripThroughHistory() +{ + Session::MessageRow user; + user.kind = Session::RowKind::User; + user.id = "u1"; + user.content = "explain this"; + user.attachments = {Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}}; + user.images = {Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}; + + Session::MessageRow thinking; + thinking.kind = Session::RowKind::Thinking; + thinking.id = "r1"; + thinking.content = "weighing options\n[Signature: sig-1...]"; + thinking.signature = "sig-1"; + thinking.usage = Session::Usage{120, 40, 80, 12}; + + Session::MessageRow tool; + tool.kind = Session::RowKind::Tool; + tool.id = "t1"; + tool.content = "read_file\nint main() {}"; + tool.toolName = "read_file"; + tool.toolArguments = QJsonObject{{"path", "main.cpp"}}; + tool.toolResult = "int main() {}"; + + Session::MessageRow assistant; + assistant.kind = Session::RowKind::Assistant; + assistant.id = "r1"; + assistant.content = "here is the answer"; + + Session::MessageRow fileEdit; + fileEdit.kind = Session::RowKind::FileEdit; + fileEdit.id = "e1"; + fileEdit.content = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\"}"; + + const QList rows{user, thinking, tool, assistant, fileEdit}; + + const Session::ConversationHistory history = Session::buildFromRows(rows); + QCOMPARE(history.size(), 2); + + const QList projected = Session::projectToRows(history); + QCOMPARE(projected, rows); +} + +void QodeAssistTest::testCompressedChatShapeReloadsAsOneAssistantRow() +{ + Session::Message summary; + summary.role = Session::MessageRole::Assistant; + summary.id = "c1"; + summary.blocks = {Session::TextBlock{"# Chat Summary\n\nwe discussed the parser"}}; + + Session::ConversationHistory compressed; + compressed.append(summary); + + const auto reloaded = Session::HistorySerializer::fromJson( + Session::HistorySerializer::toJson(compressed)); + QVERIFY(reloaded.has_value()); + + const QList rows = Session::projectToRows(*reloaded); + QCOMPARE(rows.size(), 1); + QCOMPARE(rows.at(0).kind, Session::RowKind::Assistant); + QCOMPARE(rows.at(0).content, QString("# Chat Summary\n\nwe discussed the parser")); +} + +void QodeAssistTest::testHistoryAppliesToChatModel() +{ + Chat::ChatModel model; + Session::Session session; + Chat::ChatHistoryBridge bridge(&session, &model); + + session.setHistory(historyWithoutFileEdits()); + + QCOMPARE(model.rowCount(), 4); + + QCOMPARE( + model.data(model.index(0), Chat::ChatModel::RoleType).value(), + Chat::ChatModel::ChatRole::User); + QCOMPARE(model.data(model.index(0), Chat::ChatModel::Content).toString(), QString("explain this")); + QCOMPARE(model.data(model.index(0), Chat::ChatModel::Attachments).toList().size(), 1); + QCOMPARE(model.data(model.index(0), Chat::ChatModel::Images).toList().size(), 1); + + QCOMPARE( + model.data(model.index(1), Chat::ChatModel::RoleType).value(), + Chat::ChatModel::ChatRole::Thinking); + QCOMPARE( + model.data(model.index(1), Chat::ChatModel::Content).toString(), + QString("weighing options\n[Signature: sig-1...]")); + + QCOMPARE( + model.data(model.index(2), Chat::ChatModel::RoleType).value(), + Chat::ChatModel::ChatRole::Tool); + QCOMPARE( + model.data(model.index(2), Chat::ChatModel::Content).toString(), + QString("read_file\nint main() {}")); + + QCOMPARE( + model.data(model.index(3), Chat::ChatModel::RoleType).value(), + Chat::ChatModel::ChatRole::Assistant); + QCOMPARE( + model.data(model.index(3), Chat::ChatModel::Content).toString(), + QString("here is the answer")); + + QCOMPARE(model.sessionPromptTokens(), 120); + QCOMPARE(model.sessionCompletionTokens(), 40); + QCOMPARE(model.sessionCachedPromptTokens(), 80); +} + +void QodeAssistTest::testAdjacentThinkingBlocksSurviveReload() +{ + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r1"; + assistant.blocks + = {Session::ThinkingBlock{"first", "sig-a", true}, + Session::ThinkingBlock{"second", "sig-b", true}, + Session::TextBlock{"the answer"}}; + + Session::ConversationHistory history; + history.append(assistant); + + const QList rows = Session::projectToRows(history); + + QCOMPARE(rows.size(), 3); + QCOMPARE(rows.at(0).content, QString("first\n[Signature: sig-a...]")); + QCOMPARE(rows.at(1).content, QString("second\n[Signature: sig-b...]")); + QCOMPARE(Session::buildFromRows(rows), history); +} + +void QodeAssistTest::testSessionAppendsUserTurnBeforeSending() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn( + {Session::TextBlock{"explain this"}, Session::AttachmentBlock{"notes.txt", "notes_ab.txt"}}, + std::nullopt, + Session::TurnOptions{true, false}); + + QCOMPARE(backend.turnCount(), 1); + QCOMPARE(backend.historySizeAtSend(), qsizetype(1)); + QCOMPARE(backend.lastUserBlocks().size(), 2); + QCOMPARE(backend.lastOptions(), (Session::TurnOptions{true, false})); + QVERIFY(!backend.lastContext().has_value()); + + QCOMPARE(session.history().size(), 1); + QCOMPARE(session.history().at(0).role, Session::MessageRole::User); + QCOMPARE(session.rows().size(), 1); + QCOMPARE(session.rows().at(0).content, QString("explain this")); + QCOMPARE(session.rows().at(0).attachments.size(), 1); +} + +void QodeAssistTest::testSessionStreamsTextThinkingAndTools() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt, {}); + backend.script(scriptedTurn("r1")); + + QCOMPARE(session.history().size(), 2); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.role, Session::MessageRole::Assistant); + QCOMPARE(assistant.id, QString("r1")); + QCOMPARE(assistant.usage, (Session::Usage{120, 40, 80, 12})); + QCOMPARE(assistant.blocks.size(), 4); + QCOMPARE( + assistant.blocks.at(0), + (Session::ContentBlock{Session::ThinkingBlock{"weighing options", "sig-1", false}})); + QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"let me check"}})); + QCOMPARE( + assistant.blocks.at(2), + (Session::ContentBlock{Session::ToolCallBlock{ + "t1", "read_file", QJsonObject{{"path", "main.cpp"}}, "int main() {}"}})); + QCOMPARE( + assistant.blocks.at(3), (Session::ContentBlock{Session::TextBlock{"here is the answer"}})); +} + +void QodeAssistTest::testSessionTrimsStreamedText() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script( + {Session::TurnStarted{"r1"}, + Session::TextDelta{"r1", "\n\n"}, + Session::TextDelta{"r1", " here is"}, + Session::TextDelta{"r1", " the answer \n"}}); + + QCOMPARE(session.history().at(1).blocks.size(), 1); + QCOMPARE( + session.history().at(1).blocks.at(0), + (Session::ContentBlock{Session::TextBlock{"here is the answer"}})); +} + +void QodeAssistTest::testSessionAbandoningTheTurnCancelsTheBackend() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); + + const int cancelsBeforeTruncate = backend.cancelCount(); + session.truncateRows(0); + QCOMPARE(backend.cancelCount(), cancelsBeforeTruncate + 1); + + const int cancelsBeforeLoad = backend.cancelCount(); + session.setHistory(Session::ConversationHistory{}); + QCOMPARE(backend.cancelCount(), cancelsBeforeLoad + 1); +} + +void QodeAssistTest::testSessionProjectionMatchesHistory() +{ + Chat::ChatModel model; + FakeChatBackend backend; + Session::Session session; + Chat::ChatHistoryBridge bridge(&session, &model); + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt, {}); + backend.script(scriptedTurn("r1")); + + const QList expected = Session::projectToRows(session.history()); + QCOMPARE(session.rows(), expected); + QCOMPARE(model.rowCount(), int(expected.size())); + + for (int row = 0; row < expected.size(); ++row) { + QCOMPARE( + model.data(model.index(row), Chat::ChatModel::Content).toString(), + expected.at(row).content); + } + + QCOMPARE(model.sessionPromptTokens(), 120); + QCOMPARE(model.sessionCompletionTokens(), 40); +} + +void QodeAssistTest::testSessionAccumulatesStreamedThinking() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ThinkingReceived{"r1", "step", QString(), false}, + Session::ThinkingReceived{"r1", "step one", QString(), false}, + Session::ThinkingReceived{"r1", "step one and two", QString(), false}, + Session::TextDelta{"r1", "done"}}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + QCOMPARE( + assistant.blocks.at(0), + (Session::ContentBlock{Session::ThinkingBlock{"step one and two", QString(), false}})); + QCOMPARE(session.rows().size(), 3); +} + +void QodeAssistTest::testSessionKeepsThinkingAfterToolContinuation() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ThinkingReceived{"r1", "first thought", "sig-a", false}, + Session::ToolCallStarted{"r1", "t1", "read_file", QJsonObject{}, false}, + Session::ToolCallCompleted{"r1", "t1", "read_file", "contents"}, + Session::ThinkingReceived{"r1", "second thought", "sig-b", false}, + Session::TextDelta{"r1", "done"}}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 4); + QCOMPARE( + assistant.blocks.at(0), + (Session::ContentBlock{Session::ThinkingBlock{"first thought", "sig-a", false}})); + QCOMPARE( + assistant.blocks.at(2), + (Session::ContentBlock{Session::ThinkingBlock{"second thought", "sig-b", false}})); +} + +void QodeAssistTest::testSessionDropsPreToolTextWhenAsked() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script( + {Session::TurnStarted{"r1"}, + Session::TextDelta{"r1", "leaked tool preamble"}, + Session::ToolCallStarted{"r1", "t1", "read_file", QJsonObject{}, true}, + Session::ToolCallCompleted{"r1", "t1", "read_file", "contents"}, + Session::TextDelta{"r1", "the answer"}}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + QCOMPARE( + assistant.blocks.at(0), + (Session::ContentBlock{ + Session::ToolCallBlock{"t1", "read_file", QJsonObject{}, "contents"}})); + QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"the answer"}})); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void QodeAssistTest::testSessionToolResultAppendsFileEditRow() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + const QString result + = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\",\"status\":\"pending\"}"; + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallStarted{"r1", "t1", "edit_file", QJsonObject{}, false}, + Session::ToolCallCompleted{"r1", "t1", "edit_file", result}}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::FileEditBlock{"e1", result}})); + + QVERIFY(session.updateFileEditStatus("e1", "applied", "Successfully applied")); + + const Session::MessageRow &editRow = session.rows().last(); + QCOMPARE(editRow.kind, Session::RowKind::FileEdit); + QVERIFY(editRow.content.contains("\"status\":\"applied\"")); + QVERIFY(editRow.content.contains("\"status_message\":\"Successfully applied\"")); +} + +void QodeAssistTest::testSessionIgnoresEchoedFileEditMarker() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + const QString echoed + = "Pre-compression history (/src): 1 matching message(s)\n\n[#3 assistant]\n" + "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\"}"; + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallStarted{"r1", "t1", "read_original_history", QJsonObject{}, false}, + Session::ToolCallCompleted{"r1", "t1", "read_original_history", echoed}}); + + QCOMPARE(session.history().at(1).blocks.size(), 1); + QCOMPARE(session.rows().size(), 2); +} + +void QodeAssistTest::testSessionTruncationKeepsBlockOrder() +{ + Session::Message user; + user.role = Session::MessageRole::User; + user.blocks + = {Session::TextBlock{"look at this"}, + Session::AttachmentBlock{"notes.txt", "notes_ab.txt"}, + Session::ImageBlock{"shot.png", "shot_cd.png", "image/png"}}; + + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r1"; + assistant.blocks = {Session::TextBlock{"sure"}}; + + Session::ConversationHistory history; + history.append(user); + history.append(assistant); + + Session::Session session; + session.setHistory(history); + session.truncateRows(1); + + QCOMPARE(session.history().size(), 1); + QCOMPARE(session.history().at(0).blocks, user.blocks); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void QodeAssistTest::testHistorySnapshotReportsUnreadableBlocks() +{ + QJsonObject block; + block["type"] = "from_a_newer_build"; + + QJsonObject message; + message["role"] = "assistant"; + message["blocks"] = QJsonArray{block, QJsonObject{{"type", "text"}, {"text", "kept"}}}; + + QJsonObject root; + root["version"] = Session::HistorySerializer::currentVersion(); + root["messages"] = QJsonArray{message}; + + int dropped = -1; + const auto history = Session::HistorySerializer::fromJson(root, &dropped); + + QVERIFY(history.has_value()); + QCOMPARE(dropped, 1); + QCOMPARE(history->at(0).blocks.size(), 1); +} + +void QodeAssistTest::testSessionUsageLandsOnTheTurn() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ThinkingReceived{"r1", "hmm", "sig", false}, + Session::TextDelta{"r1", "done"}, + Session::UsageReported{"r1", Session::Usage{10, 5, 2, 1}}, + Session::TurnCompleted{"r1"}}); + + QCOMPARE(session.history().at(1).usage, (Session::Usage{10, 5, 2, 1})); + QCOMPARE(session.rows().at(1).usage, (Session::Usage{10, 5, 2, 1})); + QCOMPARE(session.rows().at(2).usage, Session::Usage{}); +} + +void QodeAssistTest::testSessionIgnoresEventsFromOtherTurns() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script( + {Session::TurnStarted{"r1"}, + Session::TextDelta{"r1", "mine"}, + Session::TextDelta{"other", "not mine"}, + Session::ToolCallStarted{"other", "t9", "read_file", QJsonObject{}, false}}); + + QCOMPARE(session.history().size(), 2); + QCOMPARE(session.history().at(1).blocks.size(), 1); + QCOMPARE( + session.history().at(1).blocks.at(0), (Session::ContentBlock{Session::TextBlock{"mine"}})); +} + +void QodeAssistTest::testSessionReportsFailureWithoutStartedTurn() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + QString reported; + QObject::connect( + &session, &Session::Session::turnFailed, &session, [&reported](const QString &error) { + reported = error; + }); + + backend.script({Session::TurnFailed{QString(), "no provider configured"}}); + + QCOMPARE(reported, QString("no provider configured")); +} + +void QodeAssistTest::testSessionIgnoresFailureFromAbandonedTurn() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + int reported = 0; + QObject::connect(&session, &Session::Session::turnFailed, &session, [&reported](const QString &) { + ++reported; + }); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); + session.truncateRows(0); + + backend.script({Session::TurnFailed{"r1", "stale timeout"}}); + + QCOMPARE(reported, 0); +} + +void QodeAssistTest::testSessionTruncateRowsRewritesHistory() +{ + Chat::ChatModel model; + FakeChatBackend backend; + Session::Session session; + Chat::ChatHistoryBridge bridge(&session, &model); + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt, {}); + backend.script(scriptedTurn("r1")); + + const qsizetype before = session.rows().size(); + QVERIFY(before > 1); + + session.truncateRows(1); + + QCOMPARE(session.rows().size(), qsizetype(1)); + QCOMPARE(model.rowCount(), 1); + QCOMPARE(session.history().size(), 1); + QCOMPARE(session.history().at(0).role, Session::MessageRole::User); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void QodeAssistTest::testTurnContextCollectsPartsFromPorts() +{ + FakeProjectContext projectPort(sampleProject(), "Always use tabs."); + FakeSkillsContext skillsPort( + "# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}}); + FakeLinkedFiles filesPort({Session::LinkedFile{"main.cpp", "int main() {}"}}); + + Session::TurnContextRequest request; + request.message = "please /review this"; + request.basePrompt = "You are a helpful assistant."; + request.rolePrompt = "Act as a reviewer."; + request.linkedFilePaths = QList{"/src/main.cpp"}; + + const Session::TurnContext context + = Session::TurnContextBuilder(projectPort, &skillsPort, filesPort).build(request); + + QCOMPARE(context.basePrompt, QString("You are a helpful assistant.")); + QVERIFY(context.rolePrompt.has_value()); + QCOMPARE(*context.rolePrompt, QString("Act as a reviewer.")); + QVERIFY(context.project.available); + QCOMPARE(context.project.displayName, QString("QodeAssist")); + QCOMPARE(context.projectRules, QString("Always use tabs.")); + QCOMPARE(context.alwaysOnSkills, QString("# Always on")); + QCOMPARE(context.skillsCatalog, QString("# Skills catalog")); + QCOMPARE(context.invokedSkills.size(), 1); + QCOMPARE(context.invokedSkills.at(0).name, QString("review")); + QCOMPARE(context.linkedFilePaths, (QList{"/src/main.cpp"})); + QCOMPARE(context.linkedFiles.size(), 1); + QCOMPARE(context.linkedFiles.at(0).fileName, QString("main.cpp")); + QCOMPARE(filesPort.requestedPaths(), (QList{"/src/main.cpp"})); +} + +void QodeAssistTest::testTurnContextSkillCommandScanning() +{ + FakeProjectContext projectPort({}, QString()); + FakeSkillsContext skillsPort( + QString(), + QString(), + {Session::InvokedSkill{"review", "Review body"}, + Session::InvokedSkill{"docs", "Docs body"}, + Session::InvokedSkill{"blank", QString()}}); + FakeLinkedFiles filesPort({}); + + Session::TurnContextRequest request; + request.message = "/review then /docs then /review again /blank /unknown and mid/word"; + + const Session::TurnContext context + = Session::TurnContextBuilder(projectPort, &skillsPort, filesPort).build(request); + + QCOMPARE(context.invokedSkills.size(), 2); + QCOMPARE(context.invokedSkills.at(0).name, QString("review")); + QCOMPARE(context.invokedSkills.at(1).name, QString("docs")); +} + +void QodeAssistTest::testTurnContextWithoutSkillsPort() +{ + FakeProjectContext projectPort(sampleProject(), QString()); + FakeLinkedFiles filesPort({Session::LinkedFile{"main.cpp", "int main() {}"}}); + + Session::TurnContextRequest request; + request.message = "/review this"; + request.basePrompt = "base"; + + const Session::TurnContext context + = Session::TurnContextBuilder(projectPort, nullptr, filesPort).build(request); + + QVERIFY(context.alwaysOnSkills.isEmpty()); + QVERIFY(context.skillsCatalog.isEmpty()); + QVERIFY(context.invokedSkills.isEmpty()); + QVERIFY(context.linkedFiles.isEmpty()); + QVERIFY(filesPort.requestedPaths().isEmpty()); +} + +void QodeAssistTest::testLinkedFilesHeaderSurvivesUnreadablePaths() +{ + FakeProjectContext projectPort({}, QString()); + FakeLinkedFiles filesPort({}); + + Session::TurnContextRequest request; + request.basePrompt = "base"; + request.linkedFilePaths = QList{"/src/ignored.cpp"}; + + const Session::TurnContext context + = Session::TurnContextBuilder(projectPort, nullptr, filesPort).build(request); + + QVERIFY(context.linkedFiles.isEmpty()); + QCOMPARE(context.linkedFilePaths, (QList{"/src/ignored.cpp"})); + QCOMPARE( + Session::renderSystemPrompt(context), + QString("base\n# No active project in IDE\n\nLinked files for reference:\n")); +} + +void QodeAssistTest::testSystemPromptRenderingWithProject() +{ + Session::TurnContext context; + context.basePrompt = "You are helpful."; + context.rolePrompt = "Be terse."; + context.project = sampleProject(); + context.projectRules = "Use tabs."; + context.alwaysOnSkills = "# Always on"; + context.skillsCatalog = "# Catalog"; + context.invokedSkills = {Session::InvokedSkill{"review", "Review body"}}; + context.linkedFilePaths = QList{"/src/main.cpp"}; + context.linkedFiles = {Session::LinkedFile{"main.cpp", "int main() {}"}}; + + const QString expected + = "You are helpful." + "\n\nBe terse." + "\n# Active project: QodeAssist" + "\n# Project source root: /src/QodeAssist" + "\n# All new source files, headers, QML and CMake edits MUST be created or modified " + "under this directory. Use absolute paths rooted here, or project-relative paths." + "\n# Build output directory (compiler artifacts only — do NOT create or edit source " + "files here): /src/QodeAssist/build" + "\n# Project Rules\n\nUse tabs." + "\n\n# Always on" + "\n\n# Catalog" + "\n\n# Invoked Skill: review\n\nReview body" + "\n\nLinked files for reference:\n" + "\nFile: main.cpp\nContent:\nint main() {}\n"; + + QCOMPARE(Session::renderSystemPrompt(context), expected); +} + +void QodeAssistTest::testSystemPromptRenderingWithoutProject() +{ + Session::TurnContext context; + context.basePrompt = "You are helpful."; + + QCOMPARE( + Session::renderSystemPrompt(context), + QString("You are helpful.\n# No active project in IDE")); +} + +} // namespace QodeAssist diff --git a/tests/QodeAssistTest.hpp b/tests/QodeAssistTest.hpp new file mode 100644 index 0000000..8749e30 --- /dev/null +++ b/tests/QodeAssistTest.hpp @@ -0,0 +1,130 @@ +// Copyright (C) 2026 Petr Mironychev +// Copyright (C) 2025 Povilas Kanapickas +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include + +#include "context/DocumentContextReader.hpp" +#include "settings/CodeCompletionSettings.hpp" + +QT_BEGIN_NAMESPACE +class QTextDocument; +QT_END_NAMESPACE + +namespace QodeAssist { + +class QodeAssistTest final : public QObject +{ + Q_OBJECT + +private slots: + void testProcessTextEmpty(); + void testProcessTextCommentsAroundCodeBlock(); + void testProcessTextWithPlainCodeBlockNoNewline(); + void testProcessTextWithPlainCodeBlockWithNewline(); + void testProcessTextNoCommentsWithLanguageCodeBlock(); + void testProcessTextNoCommentsWithPlainCodeBlockNoNewline(); + void testProcessTextNoCommentsWithPlainCodeBlockWithNewline(); + void testProcessTextWithMultipleCodeBlocksDifferentLanguages(); + void testProcessTextWithMultipleCodeBlocksSameLanguage(); + void testProcessTextWithMultiplePlainCodeBlocksWithNewline(); + void testProcessTextWithMultiplePlainCodeBlocksWithoutNewline(); + void testProcessTextWithEmptyLines(); + void testProcessTextPlainCodeBlockWithNewlineWithEmptyLines(); + void testProcessTextWithoutCodeBlock(); + void testDetectLanguageFromLine(); + void testDetectLanguageFromExtension(); + void testCommentPrefixForDifferentLanguages(); + void testHasCodeBlocks(); + + void testReplaceLengthEmptyRight(); + void testReplaceLengthNoOverlap(); + void testReplaceLengthSingleCharNoOverlap(); + void testReplaceLengthLcpExtendsRight(); + void testReplaceLengthLcpPartialEngineCall(); + void testReplaceLengthLcpThenClosingTailExtendsFull(); + void testReplaceLengthClosingTailReplaceSemicolon(); + void testReplaceLengthClosingTailReplaceParen(); + void testReplaceLengthClosingTailReplaceBrackets(); + void testReplaceLengthClosingTailReplaceBracesAndSemi(); + void testReplaceLengthClosingTailWithLeadingSpace(); + void testReplaceLengthClosingTailEqualsSemi(); + void testReplaceLengthClosingTailNoMatchingClose(); + void testReplaceLengthRealCodeRightNoLcp(); + void testReplaceLengthTrailingWhitespaceOnlyLeftAlone(); + + void testCacheControlBreakpointWithoutExtendedTtl(); + void testCacheControlBreakpointWithExtendedTtl(); + void testCacheControlSystemAsStringWrappedIntoArray(); + void testCacheControlEmptySystemStringIsNotWrapped(); + void testCacheControlSystemAsArrayMarksLastBlock(); + void testCacheControlToolsLastEntryGetsCacheControl(); + void testCacheControlSingleMessageHistorySkipped(); + void testCacheControlHistoryBreakpointOnSecondToLastMessage(); + void testCacheControlHistoryArrayContentMarksLastBlock(); + void testCacheControlNoSystemNoToolsNoMessagesIsNoop(); + void testCacheControlEmptyToolsArrayIsNoop(); + + void testGetLineText(); + void testGetContext(); + void testGetContextWithCopyright(); + void testReadWholeFile(); + void testReadWholeFileWithCopyright(); + void testReadWholeFileWithMultilineCopyright(); + void testFindCopyrightSingleLine(); + void testFindCopyrightMultiLine(); + void testFindCopyrightMultipleBlocks(); + void testFindCopyrightNoCopyright(); + void testGetContextBetween(); + void testPrepareContext(); + + void testHistorySnapshotUsesCurrentVersion(); + void testHistorySnapshotRoundTrip(); + void testUnsupportedChatVersionIsRejected(); + void testChatFileWithoutMessagesArrayIsRejected(); + void testLegacyChatFileConvertsToHistory(); + void testLegacyToolRowWithoutMetadataKeepsItsText(); + void testChatRowsRoundTripThroughHistory(); + void testCompressedChatShapeReloadsAsOneAssistantRow(); + void testHistoryAppliesToChatModel(); + void testAdjacentThinkingBlocksSurviveReload(); + + void testSessionAppendsUserTurnBeforeSending(); + void testSessionStreamsTextThinkingAndTools(); + void testSessionTrimsStreamedText(); + void testSessionAbandoningTheTurnCancelsTheBackend(); + void testSessionProjectionMatchesHistory(); + void testSessionAccumulatesStreamedThinking(); + void testSessionKeepsThinkingAfterToolContinuation(); + void testSessionDropsPreToolTextWhenAsked(); + void testSessionToolResultAppendsFileEditRow(); + void testSessionIgnoresEchoedFileEditMarker(); + void testSessionTruncationKeepsBlockOrder(); + void testHistorySnapshotReportsUnreadableBlocks(); + void testSessionUsageLandsOnTheTurn(); + void testSessionIgnoresEventsFromOtherTurns(); + void testSessionReportsFailureWithoutStartedTurn(); + void testSessionIgnoresFailureFromAbandonedTurn(); + void testSessionTruncateRowsRewritesHistory(); + + void testTurnContextCollectsPartsFromPorts(); + void testTurnContextSkillCommandScanning(); + void testTurnContextWithoutSkillsPort(); + void testLinkedFilesHeaderSurvivesUnreadablePaths(); + void testSystemPromptRenderingWithProject(); + void testSystemPromptRenderingWithoutProject(); + +private: + Context::DocumentContextReader createReader(const QString &text); + QSharedPointer createSettingsForWholeFile(); + QSharedPointer createSettingsForLines( + int linesBefore, int linesAfter); + static QJsonObject expectedEphemeral(bool extendedTtl); +}; + +} // namespace QodeAssist diff --git a/tests/TestUtils.hpp b/tests/TestUtils.hpp deleted file mode 100644 index 387cbe7..0000000 --- a/tests/TestUtils.hpp +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (C) 2025 Povilas Kanapickas -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include -#include "llmcore/ContextData.hpp" -#include - -QT_BEGIN_NAMESPACE - -// gtest can't pick pretty printer when comparing QString -inline void PrintTo(const QString &value, ::std::ostream *out) -{ - *out << '"' << value.toStdString() << '"'; -} - -QT_END_NAMESPACE - -inline std::ostream &operator<<(std::ostream &out, const QString &value) -{ - out << '"' << value.toStdString() << '"'; - return out; -} - -template -std::ostream &operator<<(std::ostream &out, const QVector &value) -{ - out << "["; - for (const auto &el : value) { - out << value << ", "; - } - out << "]"; - return out; -} - -template -std::ostream &operator<<(std::ostream &out, const std::optional &value) -{ - if (value.has_value()) { - out << value.value(); - } else { - out << "(no value)"; - } - return out; -} - -namespace QodeAssist::LLMCore { - -inline std::ostream &operator<<(std::ostream &out, const Message &value) -{ - out << "Message{" - << "role=" << value.role << "content=" << value.content << "}"; - return out; -} - -inline std::ostream &operator<<(std::ostream &out, const ContextData &value) -{ - out << "ContextData{" - << "\n systemPrompt=" << value.systemPrompt << "\n prefix=" << value.prefix - << "\n suffix=" << value.suffix << "\n fileContext=" << value.fileContext - << "\n history=" << value.history << "\n}"; - return out; -} - -} // namespace QodeAssist::LLMCore diff --git a/tests/unittest_main.cpp b/tests/unittest_main.cpp deleted file mode 100644 index 831ce47..0000000 --- a/tests/unittest_main.cpp +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (C) 2025 Povilas Kanapickas -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include -#include -#include - -void silenceQtWarningNoise(QtMsgType type, const QMessageLogContext &context, const QString &msg) -{ - if (msg.startsWith("SOFT ASSERT") || msg.startsWith("Accessing MimeDatabase")) { - return; - } - qt_message_output(type, context, msg); -} - -int main(int argc, char *argv[]) -{ - qInstallMessageHandler(silenceQtWarningNoise); - QGuiApplication application(argc, argv); - testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -}