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
This commit is contained in:
Petr Mironychev
2026-07-19 22:45:04 +02:00
committed by GitHub
parent bd809edb8a
commit af85efb554
83 changed files with 5421 additions and 4620 deletions

View File

@@ -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)

View File

@@ -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 <QObject>
#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

View File

@@ -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 <QDebug>
#include <QJsonObject>
#include <QString>
#include <variant>
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<TextBlock, ThinkingBlock, ToolCallBlock, AttachmentBlock, ImageBlock, FileEditBlock>;
inline QDebug operator<<(QDebug debug, const ContentBlock &block)
{
std::visit([&debug](const auto &alternative) { debug << alternative; }, block);
return debug;
}
} // namespace QodeAssist::Session

View File

@@ -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<Message> &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<void(ContentBlock &)> &visit)
{
for (Message &message : m_messages) {
for (ContentBlock &block : message.blocks)
visit(block);
}
}
} // namespace QodeAssist::Session

View File

@@ -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 <QList>
#include <functional>
#include "session/Message.hpp"
namespace QodeAssist::Session {
class ConversationHistory
{
public:
void append(const Message &message);
qsizetype size() const;
const QList<Message> &messages() const;
const Message &at(qsizetype index) const;
const Message &last() const;
Message *lastMessage();
void visitBlocks(const std::function<void(ContentBlock &)> &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<Message> m_messages;
};
} // namespace QodeAssist::Session

View File

@@ -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 <QJsonDocument>
namespace QodeAssist::Session {
namespace {
QString fileEditMarker()
{
return QStringLiteral("QODEASSIST_FILE_EDIT:");
}
} // namespace
bool isFileEditPayload(const QString &text)
{
return text.startsWith(fileEditMarker());
}
std::optional<QJsonObject> 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

View File

@@ -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 <QJsonObject>
#include <QString>
#include <optional>
namespace QodeAssist::Session {
bool isFileEditPayload(const QString &text);
std::optional<QJsonObject> parseFileEditPayload(const QString &text);
QString encodeFileEditPayload(const QJsonObject &payload);
} // namespace QodeAssist::Session

View File

@@ -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 <optional>
#include <utility>
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<MessageRow> projectBlockToRow(const Message &message, const ContentBlock &block)
{
if (const auto *text = std::get_if<TextBlock>(&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<ThinkingBlock>(&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<ToolCallBlock>(&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<FileEditBlock>(&block)) {
MessageRow row;
row.kind = RowKind::FileEdit;
row.id = edit->id;
row.content = edit->payload;
return row;
}
return std::nullopt;
}
QList<MessageRow> projectMessageToRows(const Message &message)
{
QList<MessageRow> 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<TextBlock>(block))
textRowIndex = index;
} else if (const auto *attachment = std::get_if<AttachmentBlock>(&block)) {
rows[ensureTextRow()].attachments.append(*attachment);
} else if (const auto *image = std::get_if<ImageBlock>(&block)) {
rows[ensureTextRow()].images.append(*image);
}
}
return rows;
}
QList<MessageRow> projectToRows(const ConversationHistory &history)
{
QList<MessageRow> rows;
for (const Message &message : history.messages())
rows.append(projectMessageToRows(message));
return rows;
}
ConversationHistory buildFromRows(const QList<MessageRow> &rows)
{
ConversationHistory history;
std::optional<Message> 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

View File

@@ -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 <QJsonObject>
#include <QList>
#include <QString>
#include <optional>
#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<AttachmentBlock> attachments;
QList<ImageBlock> 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<MessageRow> projectBlockToRow(const Message &message, const ContentBlock &block);
QList<MessageRow> projectMessageToRows(const Message &message);
QList<MessageRow> projectToRows(const ConversationHistory &history);
ConversationHistory buildFromRows(const QList<MessageRow> &rows);
} // namespace QodeAssist::Session

View File

@@ -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 <QJsonArray>
#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<TextBlock>(&block)) {
json["type"] = "text";
json["text"] = text->text;
} else if (const auto *thinking = std::get_if<ThinkingBlock>(&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<ToolCallBlock>(&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<AttachmentBlock>(&block)) {
json["type"] = "attachment";
json["fileName"] = attachment->fileName;
json["storedPath"] = attachment->storedPath;
} else if (const auto *image = std::get_if<ImageBlock>(&block)) {
json["type"] = "image";
json["fileName"] = image->fileName;
json["storedPath"] = image->storedPath;
json["mediaType"] = image->mediaType;
} else if (const auto *edit = std::get_if<FileEditBlock>(&block)) {
json["type"] = "file_edit";
json["id"] = edit->id;
json["payload"] = edit->payload;
}
return json;
}
std::optional<ContentBlock> 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<MessageRow> 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<ConversationHistory> 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

View File

@@ -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 <QJsonObject>
#include <QString>
#include <optional>
#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<ConversationHistory> fromJson(
const QJsonObject &root, int *droppedBlocks = nullptr);
};
} // namespace QodeAssist::Session

View File

@@ -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 <QList>
#include <QString>
#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<ContentBlock> 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

427
sources/session/Session.cpp Normal file
View File

@@ -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 <QUuid>
#include <algorithm>
#include "session/FileEditPayload.hpp"
namespace QodeAssist::Session {
namespace {
std::optional<FileEditBlock> 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<MessageRow> &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<int>(m_rows.size()) - rowIndex;
m_rows.remove(rowIndex, removed);
m_history = kept;
emit rowsRemoved(rowIndex, removed);
}
void Session::sendTurn(
const QList<ContentBlock> &userBlocks,
const std::optional<TurnContext> &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<FileEditBlock>(&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<SessionEvent> == 8,
"SessionEvent gained an alternative; extend the dispatch below or it is silently dropped");
if (const auto *started = std::get_if<TurnStarted>(&event)) {
m_activeTurnId = started->turnId;
m_assistantRowStart = -1;
emit turnStarted(started->turnId);
return;
}
if (const auto *failure = std::get_if<TurnFailed>(&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<TextDelta>(&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<TextBlock>(&message.blocks.last())) {
last->text = text;
return;
}
}
message.blocks.append(TextBlock{text});
});
} else if (const auto *thinking = std::get_if<ThinkingReceived>(&event)) {
m_textSegment.clear();
mutateAssistantTail([thinking](Message &message) {
if (!message.blocks.isEmpty()) {
if (auto *last = std::get_if<ThinkingBlock>(&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<ToolCallStarted>(&event)) {
m_textSegment.clear();
mutateAssistant([toolStart](Message &message) {
if (toolStart->dropPrecedingText && !message.blocks.isEmpty()
&& std::holds_alternative<TextBlock>(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<ToolCallCompleted>(&event)) {
m_textSegment.clear();
mutateAssistant([toolEnd](Message &message) {
for (auto it = message.blocks.rbegin(); it != message.blocks.rend(); ++it) {
auto *tool = std::get_if<ToolCallBlock>(&*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<UsageReported>(&event)) {
if (Message *assistant = activeAssistantMessage()) {
assistant->usage = usage->usage;
syncAssistantRows();
}
emit usageReceived(usage->usage);
} else if (const auto *completed = std::get_if<TurnCompleted>(&event)) {
const QString turnId = completed->turnId;
endTurn();
emit turnFinished(turnId);
}
}
void Session::appendMessage(const Message &message)
{
m_history.append(message);
const QList<MessageRow> 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<int>(m_rows.size());
m_history.append(message);
}
Message *Session::activeAssistantMessage()
{
return m_assistantRowStart < 0 ? nullptr : m_history.lastMessage();
}
void Session::mutateAssistant(const std::function<void(Message &)> &mutate)
{
ensureAssistantMessage();
Message *assistant = activeAssistantMessage();
if (!assistant)
return;
mutate(*assistant);
syncAssistantRows();
}
void Session::mutateAssistantTail(const std::function<void(Message &)> &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<int>(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<MessageRow> fresh = projectMessageToRows(*assistant);
const int start = m_assistantRowStart;
const int previous = static_cast<int>(m_rows.size()) - start;
const int common = std::min(previous, static_cast<int>(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<MessageRow> added = fresh.mid(previous);
m_rows.append(added);
emit rowsAppended(added);
} else if (fresh.size() < previous) {
const int removed = previous - static_cast<int>(fresh.size());
m_rows.remove(start + fresh.size(), removed);
emit rowsRemoved(start + static_cast<int>(fresh.size()), removed);
}
}
void Session::endTurn()
{
m_activeTurnId.clear();
m_textSegment.clear();
m_assistantRowStart = -1;
}
} // namespace QodeAssist::Session

View File

@@ -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 <QList>
#include <QObject>
#include <QPointer>
#include <QString>
#include <functional>
#include <optional>
#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<MessageRow> &rows() const;
void setHistory(const ConversationHistory &history);
void clear();
void truncateRows(int rowIndex);
void sendTurn(
const QList<ContentBlock> &userBlocks,
const std::optional<TurnContext> &context,
const TurnOptions &options);
void cancel();
bool updateFileEditStatus(
const QString &editId, const QString &status, const QString &statusMessage = {});
signals:
void rowsAppended(const QList<QodeAssist::Session::MessageRow> &rows);
void rowUpdated(int index, const QodeAssist::Session::MessageRow &row);
void rowsRemoved(int first, int count);
void rowsReset(const QList<QodeAssist::Session::MessageRow> &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<void(Message &)> &mutate);
void mutateAssistantTail(const std::function<void(Message &)> &mutate);
void ensureAssistantMessage();
void updateLastAssistantRow();
void syncAssistantRows();
void endTurn();
ConversationHistory m_history;
QList<MessageRow> m_rows;
QPointer<ChatBackend> m_backend;
QString m_activeTurnId;
QString m_textSegment;
int m_assistantRowStart = -1;
};
} // namespace QodeAssist::Session

View File

@@ -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

View File

@@ -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 <QJsonObject>
#include <QMetaType>
#include <QString>
#include <variant>
#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)

View File

@@ -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

View File

@@ -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 <QList>
#include <QString>
#include <optional>
namespace QodeAssist::Session {
struct ProjectInfo
{
bool available = false;
QString displayName;
QString sourceRoot;
std::optional<QString> 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<QString> rolePrompt;
ProjectInfo project;
QString projectRules;
QString alwaysOnSkills;
QString skillsCatalog;
QList<InvokedSkill> invokedSkills;
QList<QString> linkedFilePaths;
QList<LinkedFile> linkedFiles;
bool operator==(const TurnContext &other) const = default;
};
QString renderSystemPrompt(const TurnContext &context);
} // namespace QodeAssist::Session

View File

@@ -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 <QRegularExpression>
#include <QStringList>
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

View File

@@ -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 <QList>
#include <QString>
#include <optional>
#include "session/TurnContext.hpp"
#include "session/TurnContextPorts.hpp"
namespace QodeAssist::Session {
struct TurnContextRequest
{
QString message;
QString basePrompt;
std::optional<QString> rolePrompt;
QList<QString> 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

View File

@@ -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 <QList>
#include <QString>
#include <QtGlobal>
#include <optional>
#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<InvokedSkill> findSkill(const QString &name) const = 0;
};
class ILinkedFilesPort
{
Q_DISABLE_COPY_MOVE(ILinkedFilesPort)
public:
ILinkedFilesPort() = default;
virtual ~ILinkedFilesPort() = default;
virtual QList<LinkedFile> readFiles(const QList<QString> &paths) const = 0;
};
} // namespace QodeAssist::Session

View File

@@ -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 <QList>
#include <optional>
#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<ContentBlock> userBlocks;
const ConversationHistory *history = nullptr;
std::optional<TurnContext> context;
TurnOptions options;
};
} // namespace QodeAssist::Session