feat: add support acp in common chat (#369)

This commit is contained in:
Petr Mironychev
2026-07-21 13:10:00 +02:00
committed by GitHub
parent af85efb554
commit 442263a6a7
158 changed files with 14270 additions and 4118 deletions

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
#include "session/AgentPlan.hpp"
#include <QJsonArray>
#include "session/BlockCodec.hpp"
namespace QodeAssist::Session {
QJsonObject planBlockToJson(const PlanBlock &block)
{
QJsonArray entries;
for (const PlanEntry &entry : block.entries) {
QJsonObject json{{"content", entry.content}, {"status", entry.status}};
if (!entry.priority.isEmpty())
json["priority"] = entry.priority;
entries.append(json);
}
return QJsonObject{{"entries", entries}};
}
PlanBlock planBlockFromJson(const QJsonObject &json)
{
PlanBlock block;
const QJsonArray entries = json["entries"].toArray();
for (const QJsonValue &value : entries) {
const QJsonObject entry = value.toObject();
block.entries.append(
PlanEntry{
entry["content"].toString(),
entry["priority"].toString(),
entry["status"].toString()});
}
return block;
}
QString encodePlanBlock(const PlanBlock &block)
{
return encodeMarkerPayload(planPayloadMarker, planBlockToJson(block));
}
std::optional<PlanBlock> decodePlanBlock(const QString &text)
{
const auto payload = decodeMarkerPayload(planPayloadMarker, text);
if (!payload)
return std::nullopt;
return planBlockFromJson(*payload);
}
} // 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
#pragma once
#include <QDebug>
#include <QJsonObject>
#include <QList>
#include <QString>
#include <optional>
namespace QodeAssist::Session {
struct PlanEntry
{
QString content;
QString priority;
QString status;
bool operator==(const PlanEntry &other) const = default;
friend QDebug operator<<(QDebug debug, const PlanEntry &entry)
{
return debug.nospace() << "PlanEntry(" << entry.content << ", " << entry.priority << ", "
<< entry.status << ")";
}
};
struct PlanBlock
{
QList<PlanEntry> entries;
bool operator==(const PlanBlock &other) const = default;
friend QDebug operator<<(QDebug debug, const PlanBlock &block)
{
return debug.nospace() << "Plan(" << block.entries << ")";
}
};
QJsonObject planBlockToJson(const PlanBlock &block);
PlanBlock planBlockFromJson(const QJsonObject &json);
QString encodePlanBlock(const PlanBlock &block);
std::optional<PlanBlock> decodePlanBlock(const QString &text);
} // namespace QodeAssist::Session

View File

@@ -0,0 +1,38 @@
// 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/BlockCodec.hpp"
#include <QJsonDocument>
namespace QodeAssist::Session {
QList<QLatin1StringView> knownPayloadMarkers()
{
return {payloadMarkers.begin(), payloadMarkers.end()};
}
bool hasPayloadMarker(QLatin1StringView marker, const QString &text)
{
return text.startsWith(marker);
}
QString encodeMarkerPayload(QLatin1StringView marker, const QJsonObject &payload)
{
return marker + QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact));
}
std::optional<QJsonObject> decodeMarkerPayload(QLatin1StringView marker, const QString &text)
{
if (!hasPayloadMarker(marker, text))
return std::nullopt;
const QJsonDocument document = QJsonDocument::fromJson(text.mid(marker.size()).toUtf8());
if (!document.isObject())
return std::nullopt;
return document.object();
}
} // namespace QodeAssist::Session

View File

@@ -0,0 +1,32 @@
// 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 <QLatin1StringView>
#include <QList>
#include <QString>
#include <array>
#include <optional>
namespace QodeAssist::Session {
inline constexpr std::array<QLatin1StringView, 3> payloadMarkers{
QLatin1StringView{"QODEASSIST_FILE_EDIT:"},
QLatin1StringView{"QODEASSIST_PERMISSION:"},
QLatin1StringView{"QODEASSIST_PLAN:"}};
inline constexpr QLatin1StringView fileEditPayloadMarker = payloadMarkers[0];
inline constexpr QLatin1StringView permissionPayloadMarker = payloadMarkers[1];
inline constexpr QLatin1StringView planPayloadMarker = payloadMarkers[2];
QList<QLatin1StringView> knownPayloadMarkers();
bool hasPayloadMarker(QLatin1StringView marker, const QString &text);
QString encodeMarkerPayload(QLatin1StringView marker, const QJsonObject &payload);
std::optional<QJsonObject> decodeMarkerPayload(QLatin1StringView marker, const QString &text);
} // namespace QodeAssist::Session

View File

@@ -1,11 +1,16 @@
add_library(QodeAssistSession STATIC
AgentPlan.hpp AgentPlan.cpp
BlockCodec.hpp BlockCodec.cpp
ContentBlock.hpp
Message.hpp
ConversationHistory.hpp ConversationHistory.cpp
FencedText.hpp FencedText.cpp
FileEditPayload.hpp FileEditPayload.cpp
HistoryProjection.hpp HistoryProjection.cpp
HistorySerializer.hpp HistorySerializer.cpp
PermissionRequest.hpp PermissionRequest.cpp
SessionEvent.hpp SessionEvent.cpp
TurnLedger.hpp TurnLedger.cpp
TurnRequest.hpp
ChatBackend.hpp
Session.hpp Session.cpp

View File

@@ -21,6 +21,18 @@ public:
virtual void sendTurn(const TurnRequest &request) = 0;
virtual void cancel() = 0;
virtual bool respondPermission(const QString &requestId, const QString &optionId)
{
Q_UNUSED(requestId)
Q_UNUSED(optionId)
return false;
}
virtual TurnContextNeeds contextNeeds() const { return {}; }
virtual void setChatFilePath(const QString &filePath) { Q_UNUSED(filePath) }
virtual void clearToolSession(const QString &filePath) { Q_UNUSED(filePath) }
signals:
void sessionEvent(const QodeAssist::Session::SessionEvent &event);
};

View File

@@ -10,6 +10,9 @@
#include <variant>
#include "session/AgentPlan.hpp"
#include "session/PermissionRequest.hpp"
namespace QodeAssist::Session {
struct TextBlock
@@ -45,6 +48,10 @@ struct ToolCallBlock
QString name;
QJsonObject arguments;
QString result;
QString kind;
QString status;
QJsonObject details;
bool fromAgent = false;
bool operator==(const ToolCallBlock &other) const = default;
@@ -52,7 +59,9 @@ struct ToolCallBlock
{
return debug.nospace() << "ToolCall(" << block.id << ", " << block.name
<< ", args=" << block.arguments << ", result=" << block.result
<< ")";
<< ", kind=" << block.kind << ", status=" << block.status
<< ", details=" << block.details
<< ", fromAgent=" << block.fromAgent << ")";
}
};
@@ -98,8 +107,15 @@ struct FileEditBlock
}
};
using ContentBlock = std::
variant<TextBlock, ThinkingBlock, ToolCallBlock, AttachmentBlock, ImageBlock, FileEditBlock>;
using ContentBlock = std::variant<
TextBlock,
ThinkingBlock,
ToolCallBlock,
AttachmentBlock,
ImageBlock,
FileEditBlock,
PermissionBlock,
PlanBlock>;
inline QDebug operator<<(QDebug debug, const ContentBlock &block)
{

View File

@@ -0,0 +1,36 @@
// 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/FencedText.hpp"
namespace QodeAssist::Session {
namespace {
constexpr int minimumFenceLength = 3;
int longestBacktickRun(const QString &content)
{
int longest = 0;
int current = 0;
for (const QChar character : content) {
current = character == QLatin1Char('`') ? current + 1 : 0;
longest = qMax(longest, current);
}
return longest;
}
} // namespace
QString fencedFileBlock(const QString &fileName, const QString &content)
{
const int fenceLength = qMax(minimumFenceLength, longestBacktickRun(content) + 1);
const QString fence(fenceLength, QLatin1Char('`'));
return QStringLiteral("File: %1\n%2\n%3\n%2").arg(fileName, fence, content);
}
} // namespace QodeAssist::Session

View File

@@ -0,0 +1,13 @@
// 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 <QString>
namespace QodeAssist::Session {
QString fencedFileBlock(const QString &fileName, const QString &content);
} // namespace QodeAssist::Session

View File

@@ -4,46 +4,23 @@
#include "session/FileEditPayload.hpp"
#include <QJsonDocument>
#include "session/BlockCodec.hpp"
namespace QodeAssist::Session {
namespace {
QString fileEditMarker()
{
return QStringLiteral("QODEASSIST_FILE_EDIT:");
}
} // namespace
bool isFileEditPayload(const QString &text)
{
return text.startsWith(fileEditMarker());
return hasPayloadMarker(fileEditPayloadMarker, text);
}
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();
return decodeMarkerPayload(fileEditPayloadMarker, text);
}
QString encodeFileEditPayload(const QJsonObject &payload)
{
return fileEditMarker()
+ QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact));
return encodeMarkerPayload(fileEditPayloadMarker, payload);
}
} // namespace QodeAssist::Session

View File

@@ -7,6 +7,12 @@
#include <optional>
#include <utility>
#if defined(Q_CC_MSVC)
#pragma warning(error : 4062)
#else
#pragma GCC diagnostic error "-Wswitch"
#endif
namespace QodeAssist::Session {
namespace {
@@ -52,7 +58,15 @@ QString toolDisplayText(const ToolCallBlock &block)
ToolCallBlock toolCallOf(const MessageRow &row)
{
ToolCallBlock block{row.id, row.toolName, row.toolArguments, row.toolResult};
ToolCallBlock block{
row.id,
row.toolName,
row.toolArguments,
row.toolResult,
row.toolKind,
restoredToolStatus(row.toolStatus),
row.toolDetails,
row.kind == RowKind::AgentTool};
if (block.name.isEmpty() && block.result.isEmpty())
block.result = row.content;
return block;
@@ -73,8 +87,87 @@ RowKind textRowKind(MessageRole role)
} // namespace
bool isTerminalToolStatus(const QString &status)
{
return status.isEmpty() || status == QLatin1String("completed")
|| status == QLatin1String("failed") || status == QLatin1String("interrupted");
}
QString restoredToolStatus(QString status)
{
if (!isTerminalToolStatus(status))
return QStringLiteral("interrupted");
return status;
}
bool isTranscriptOnlyRow(RowKind kind)
{
switch (kind) {
case RowKind::AgentTool:
case RowKind::FileEdit:
case RowKind::Permission:
case RowKind::Plan:
return true;
case RowKind::User:
case RowKind::Assistant:
case RowKind::System:
case RowKind::Tool:
case RowKind::Thinking:
return false;
}
return false;
}
RowTreatment rowTreatmentFor(RowAudience audience, RowKind kind)
{
switch (audience) {
case RowAudience::Prompt:
switch (kind) {
case RowKind::User:
return RowTreatment::UserText;
case RowKind::Assistant:
case RowKind::System:
return RowTreatment::AssistantText;
case RowKind::Thinking:
return RowTreatment::AssistantThinking;
case RowKind::Tool:
return RowTreatment::ToolExchange;
case RowKind::AgentTool:
case RowKind::FileEdit:
case RowKind::Permission:
case RowKind::Plan:
return RowTreatment::Omit;
}
break;
case RowAudience::Compression:
switch (kind) {
case RowKind::User:
return RowTreatment::UserText;
case RowKind::Assistant:
case RowKind::System:
return RowTreatment::AssistantText;
case RowKind::Thinking:
case RowKind::Tool:
case RowKind::AgentTool:
case RowKind::FileEdit:
case RowKind::Permission:
case RowKind::Plan:
return RowTreatment::Omit;
}
break;
case RowAudience::TokenCount:
return rowTreatmentFor(RowAudience::Prompt, kind);
}
return RowTreatment::Omit;
}
std::optional<MessageRow> projectBlockToRow(const Message &message, const ContentBlock &block)
{
static_assert(
std::variant_size_v<ContentBlock> == 8,
"ContentBlock gained an alternative; extend the projection below or the block lives in the "
"history without ever rendering");
if (const auto *text = std::get_if<TextBlock>(&block)) {
MessageRow row;
row.kind = textRowKind(message.role);
@@ -95,12 +188,15 @@ std::optional<MessageRow> projectBlockToRow(const Message &message, const Conten
if (const auto *tool = std::get_if<ToolCallBlock>(&block)) {
MessageRow row;
row.kind = RowKind::Tool;
row.kind = tool->fromAgent ? RowKind::AgentTool : RowKind::Tool;
row.id = tool->id;
row.content = toolDisplayText(*tool);
row.toolName = tool->name;
row.toolArguments = tool->arguments;
row.toolResult = tool->result;
row.toolKind = tool->kind;
row.toolStatus = tool->status;
row.toolDetails = tool->details;
return row;
}
@@ -112,6 +208,22 @@ std::optional<MessageRow> projectBlockToRow(const Message &message, const Conten
return row;
}
if (const auto *permission = std::get_if<PermissionBlock>(&block)) {
MessageRow row;
row.kind = RowKind::Permission;
row.id = permission->requestId;
row.content = encodePermissionBlock(*permission);
return row;
}
if (const auto *plan = std::get_if<PlanBlock>(&block)) {
MessageRow row;
row.kind = RowKind::Plan;
row.id = message.id;
row.content = encodePlanBlock(*plan);
return row;
}
return std::nullopt;
}
@@ -123,7 +235,7 @@ QList<MessageRow> projectMessageToRows(const Message &message)
qsizetype textRowIndex = -1;
auto appendRow = [&](MessageRow row) {
if (!usageAssigned && row.id == message.id) {
if (!usageAssigned && row.id == message.id && !isTranscriptOnlyRow(row.kind)) {
row.usage = message.usage;
usageAssigned = true;
}
@@ -223,7 +335,8 @@ ConversationHistory buildFromRows(const QList<MessageRow> &rows)
message.usage = row.usage;
break;
}
case RowKind::Tool: {
case RowKind::Tool:
case RowKind::AgentTool: {
Message &message = openAssistant();
message.blocks.append(toolCallOf(row));
break;
@@ -233,6 +346,26 @@ ConversationHistory buildFromRows(const QList<MessageRow> &rows)
message.blocks.append(FileEditBlock{row.id, row.content});
break;
}
case RowKind::Permission: {
Message &message = openAssistant();
if (auto permission = decodePermissionBlock(row.content))
message.blocks.append(restoredPermissionBlock(*permission));
else
message.blocks.append(TextBlock{row.content});
break;
}
case RowKind::Plan: {
Message &message = openAssistant();
if (message.id.isEmpty())
message.id = row.id;
if (!row.usage.isEmpty())
message.usage = row.usage;
if (auto plan = decodePlanBlock(row.content))
message.blocks.append(*plan);
else
message.blocks.append(TextBlock{row.content});
break;
}
}
}

View File

@@ -14,7 +14,17 @@
namespace QodeAssist::Session {
enum class RowKind { User, Assistant, System, Tool, FileEdit, Thinking };
enum class RowKind {
User,
Assistant,
System,
Tool,
AgentTool,
FileEdit,
Thinking,
Permission,
Plan
};
struct MessageRow
{
@@ -26,6 +36,9 @@ struct MessageRow
QString toolName;
QJsonObject toolArguments;
QString toolResult;
QString toolKind;
QString toolStatus;
QJsonObject toolDetails;
QList<AttachmentBlock> attachments;
QList<ImageBlock> images;
Usage usage;
@@ -38,11 +51,23 @@ struct MessageRow
<< ", content=" << row.content << ", sig=" << row.signature
<< ", redacted=" << row.redacted << ", tool=" << row.toolName
<< ", args=" << row.toolArguments << ", result=" << row.toolResult
<< ", toolKind=" << row.toolKind << ", toolStatus=" << row.toolStatus
<< ", toolDetails=" << row.toolDetails
<< ", attachments=" << row.attachments << ", images=" << row.images
<< ", " << row.usage << ")";
}
};
enum class RowAudience { Prompt, Compression, TokenCount };
enum class RowTreatment { Omit, UserText, AssistantText, AssistantThinking, ToolExchange };
bool isTranscriptOnlyRow(RowKind kind);
RowTreatment rowTreatmentFor(RowAudience audience, RowKind kind);
bool isTerminalToolStatus(const QString &status);
QString restoredToolStatus(QString status);
std::optional<MessageRow> projectBlockToRow(const Message &message, const ContentBlock &block);
QList<MessageRow> projectMessageToRows(const Message &message);
QList<MessageRow> projectToRows(const ConversationHistory &history);

View File

@@ -78,6 +78,11 @@ Usage usageFromJson(const QJsonObject &json)
QJsonObject blockToJson(const ContentBlock &block)
{
static_assert(
std::variant_size_v<ContentBlock> == 8,
"ContentBlock gained an alternative; extend blockToJson and blockFromJson below or it is "
"written without a type and dropped on the next load");
QJsonObject json;
if (const auto *text = std::get_if<TextBlock>(&block)) {
@@ -98,6 +103,14 @@ QJsonObject blockToJson(const ContentBlock &block)
json["arguments"] = tool->arguments;
if (!tool->result.isEmpty())
json["result"] = tool->result;
if (!tool->kind.isEmpty())
json["kind"] = tool->kind;
if (!tool->status.isEmpty())
json["status"] = tool->status;
if (!tool->details.isEmpty())
json["details"] = tool->details;
if (tool->fromAgent)
json["fromAgent"] = true;
} else if (const auto *attachment = std::get_if<AttachmentBlock>(&block)) {
json["type"] = "attachment";
json["fileName"] = attachment->fileName;
@@ -111,6 +124,12 @@ QJsonObject blockToJson(const ContentBlock &block)
json["type"] = "file_edit";
json["id"] = edit->id;
json["payload"] = edit->payload;
} else if (const auto *permission = std::get_if<PermissionBlock>(&block)) {
json = permissionBlockToJson(*permission);
json["type"] = "permission";
} else if (const auto *plan = std::get_if<PlanBlock>(&block)) {
json = planBlockToJson(*plan);
json["type"] = "plan";
}
return json;
@@ -133,7 +152,11 @@ std::optional<ContentBlock> blockFromJson(const QJsonObject &json)
json["id"].toString(),
json["name"].toString(),
json["arguments"].toObject(),
json["result"].toString()}};
json["result"].toString(),
json["kind"].toString(),
restoredToolStatus(json["status"].toString()),
json["details"].toObject(),
json["fromAgent"].toBool(false)}};
}
if (type == QLatin1String("attachment"))
@@ -150,6 +173,12 @@ std::optional<ContentBlock> blockFromJson(const QJsonObject &json)
if (type == QLatin1String("file_edit"))
return ContentBlock{FileEditBlock{json["id"].toString(), json["payload"].toString()}};
if (type == QLatin1String("permission"))
return ContentBlock{restoredPermissionBlock(permissionBlockFromJson(json))};
if (type == QLatin1String("plan"))
return ContentBlock{planBlockFromJson(json)};
return std::nullopt;
}

View File

@@ -0,0 +1,116 @@
// 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/PermissionRequest.hpp"
#include <QJsonArray>
#include "session/BlockCodec.hpp"
namespace QodeAssist::Session {
QString permissionStatusToString(PermissionStatus status)
{
switch (status) {
case PermissionStatus::Pending:
return QStringLiteral("pending");
case PermissionStatus::Answered:
return QStringLiteral("answered");
case PermissionStatus::Cancelled:
return QStringLiteral("cancelled");
}
return QStringLiteral("pending");
}
PermissionStatus permissionStatusFromString(const QString &status)
{
if (status == QLatin1String("answered"))
return PermissionStatus::Answered;
if (status == QLatin1String("cancelled"))
return PermissionStatus::Cancelled;
return PermissionStatus::Pending;
}
std::optional<PermissionOption> PermissionBlock::option(const QString &optionId) const
{
for (const PermissionOption &candidate : options) {
if (candidate.id == optionId)
return candidate;
}
return std::nullopt;
}
QJsonObject permissionBlockToJson(const PermissionBlock &block)
{
QJsonArray options;
for (const PermissionOption &option : block.options) {
options.append(
QJsonObject{
{"id", option.id},
{"name", option.name},
{"kind", option.kind},
{"allows", option.allows()}});
}
QJsonObject json;
json["requestId"] = block.requestId;
json["title"] = block.title;
json["options"] = options;
json["status"] = permissionStatusToString(block.status);
if (!block.toolCallId.isEmpty())
json["toolCallId"] = block.toolCallId;
if (!block.toolKind.isEmpty())
json["toolKind"] = block.toolKind;
if (!block.selectedOptionId.isEmpty())
json["selectedOptionId"] = block.selectedOptionId;
if (block.automatic)
json["automatic"] = true;
return json;
}
PermissionBlock permissionBlockFromJson(const QJsonObject &json)
{
PermissionBlock block;
block.requestId = json["requestId"].toString();
block.toolCallId = json["toolCallId"].toString();
block.title = json["title"].toString();
block.toolKind = json["toolKind"].toString();
block.status = permissionStatusFromString(json["status"].toString());
block.selectedOptionId = json["selectedOptionId"].toString();
block.automatic = json["automatic"].toBool(false);
const QJsonArray options = json["options"].toArray();
for (const QJsonValue &value : options) {
const QJsonObject option = value.toObject();
block.options.append(
PermissionOption{
option["id"].toString(), option["name"].toString(), option["kind"].toString()});
}
return block;
}
PermissionBlock restoredPermissionBlock(PermissionBlock block)
{
if (block.status == PermissionStatus::Pending)
block.status = PermissionStatus::Cancelled;
return block;
}
QString encodePermissionBlock(const PermissionBlock &block)
{
return encodeMarkerPayload(permissionPayloadMarker, permissionBlockToJson(block));
}
std::optional<PermissionBlock> decodePermissionBlock(const QString &text)
{
const auto payload = decodeMarkerPayload(permissionPayloadMarker, text);
if (!payload)
return std::nullopt;
return permissionBlockFromJson(*payload);
}
} // namespace QodeAssist::Session

View File

@@ -0,0 +1,87 @@
// 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 <QList>
#include <QString>
#include <optional>
namespace QodeAssist::Session {
namespace PermissionOptionKind {
inline constexpr QLatin1StringView AllowOnce{"allow_once"};
inline constexpr QLatin1StringView AllowAlways{"allow_always"};
inline constexpr QLatin1StringView RejectOnce{"reject_once"};
inline constexpr QLatin1StringView RejectAlways{"reject_always"};
} // namespace PermissionOptionKind
struct PermissionOption
{
QString id;
QString name;
QString kind;
bool allows() const
{
return kind == PermissionOptionKind::AllowOnce || kind == PermissionOptionKind::AllowAlways;
}
bool scopesToConversation() const
{
return kind == PermissionOptionKind::AllowAlways
|| kind == PermissionOptionKind::RejectAlways;
}
bool operator==(const PermissionOption &other) const = default;
friend QDebug operator<<(QDebug debug, const PermissionOption &option)
{
return debug.nospace() << "Option(" << option.id << ", " << option.name << ", "
<< option.kind << ")";
}
};
enum class PermissionStatus { Pending, Answered, Cancelled };
QString permissionStatusToString(PermissionStatus status);
PermissionStatus permissionStatusFromString(const QString &status);
struct PermissionBlock
{
QString requestId;
QString toolCallId;
QString title;
QString toolKind;
QList<PermissionOption> options;
PermissionStatus status = PermissionStatus::Pending;
QString selectedOptionId;
bool automatic = false;
std::optional<PermissionOption> option(const QString &optionId) const;
bool operator==(const PermissionBlock &other) const = default;
friend QDebug operator<<(QDebug debug, const PermissionBlock &block)
{
return debug.nospace() << "Permission(" << block.requestId << ", " << block.title
<< ", kind=" << block.toolKind << ", options=" << block.options
<< ", status=" << permissionStatusToString(block.status)
<< ", selected=" << block.selectedOptionId
<< ", automatic=" << block.automatic << ")";
}
};
QJsonObject permissionBlockToJson(const PermissionBlock &block);
PermissionBlock permissionBlockFromJson(const QJsonObject &json);
PermissionBlock restoredPermissionBlock(PermissionBlock block);
QString encodePermissionBlock(const PermissionBlock &block);
std::optional<PermissionBlock> decodePermissionBlock(const QString &text);
} // namespace QodeAssist::Session

View File

@@ -4,6 +4,7 @@
#include "session/Session.hpp"
#include <QJsonArray>
#include <QUuid>
#include <algorithm>
@@ -68,8 +69,10 @@ void Session::setBackend(ChatBackend *backend)
if (m_backend == backend)
return;
if (m_backend)
if (m_backend) {
cancel();
disconnect(m_backend, nullptr, this, nullptr);
}
m_backend = backend;
@@ -91,6 +94,9 @@ const QList<MessageRow> &Session::rows() const
void Session::setHistory(const ConversationHistory &history)
{
cancel();
m_pendingPermissions.clear();
m_alwaysAllowedToolKinds.clear();
m_alwaysRejectedToolKinds.clear();
m_history = history;
m_rows = projectToRows(m_history);
emit rowsReset(m_rows);
@@ -131,9 +137,7 @@ void Session::truncateRows(int rowIndex)
}
void Session::sendTurn(
const QList<ContentBlock> &userBlocks,
const std::optional<TurnContext> &context,
const TurnOptions &options)
const QList<ContentBlock> &userBlocks, const std::optional<TurnContext> &context)
{
if (!m_backend)
return;
@@ -149,7 +153,6 @@ void Session::sendTurn(
request.userBlocks = userBlocks;
request.history = &m_history;
request.context = context;
request.options = options;
m_backend->sendTurn(request);
}
@@ -162,6 +165,378 @@ void Session::cancel()
m_backend->cancel();
}
bool Session::isPermissionPending(const QString &requestId) const
{
return m_pendingPermissions.contains(requestId);
}
void Session::respondPermission(const QString &requestId, const QString &optionId)
{
if (!isPermissionPending(requestId)) {
qWarning("QodeAssist: ignoring an answer to permission request %s, which is not pending",
qUtf8Printable(requestId));
return;
}
const std::optional<PermissionBlock> block = permissionBlock(requestId);
if (!block) {
qWarning("QodeAssist: permission request %s has no record to answer",
qUtf8Printable(requestId));
applyPermissionResolution(
PermissionResolved{.turnId = m_activeTurnId, .requestId = requestId, .cancelled = true});
return;
}
if (!block->option(optionId)) {
qWarning("QodeAssist: permission request %s was not offered option %s",
qUtf8Printable(requestId), qUtf8Printable(optionId));
return;
}
if (m_backend && m_backend->respondPermission(requestId, optionId))
return;
qWarning("QodeAssist: no backend accepted the answer to permission request %s",
qUtf8Printable(requestId));
applyPermissionResolution(
PermissionResolved{.turnId = m_activeTurnId, .requestId = requestId, .cancelled = true});
}
std::optional<PermissionBlock> Session::permissionBlock(const QString &requestId) const
{
const QList<Message> &messages = m_history.messages();
for (auto message = messages.crbegin(); message != messages.crend(); ++message) {
for (auto block = message->blocks.crbegin(); block != message->blocks.crend(); ++block) {
if (const auto *permission = std::get_if<PermissionBlock>(&*block);
permission && permission->requestId == requestId) {
return *permission;
}
}
}
return std::nullopt;
}
QString Session::autoAnswerOptionFor(const PermissionRequested &request) const
{
if (request.toolKind.isEmpty())
return {};
const bool rejected = m_alwaysRejectedToolKinds.contains(request.toolKind);
if (!rejected && !m_alwaysAllowedToolKinds.contains(request.toolKind))
return {};
const QLatin1StringView once = rejected ? PermissionOptionKind::RejectOnce
: PermissionOptionKind::AllowOnce;
const QLatin1StringView always = rejected ? PermissionOptionKind::RejectAlways
: PermissionOptionKind::AllowAlways;
QString fallback;
for (const PermissionOption &option : request.options) {
if (option.kind == once)
return option.id;
if (option.kind == always && fallback.isEmpty())
fallback = option.id;
}
return fallback;
}
bool Session::refreshAssistantBlockRow(
const ContentBlock &block, RowKind kind, const QString &rowId)
{
const Message *assistant = activeAssistantMessage();
if (!assistant || m_assistantRowStart < 0)
return false;
auto fresh = projectBlockToRow(*assistant, block);
if (!fresh)
return false;
for (int i = static_cast<int>(m_rows.size()) - 1; i >= m_assistantRowStart; --i) {
if (m_rows[i].kind != kind || m_rows[i].id != rowId)
continue;
fresh->usage = m_rows.at(i).usage;
if (m_rows.at(i) == *fresh)
return true;
m_rows[i] = *fresh;
emit rowUpdated(i, *fresh);
return true;
}
return false;
}
void Session::applyToolCall(const ToolCallUpdated &update)
{
ensureAssistantMessage();
Message *assistant = activeAssistantMessage();
if (!assistant)
return;
const auto hasFileEdit = [assistant](const QString &editId) {
for (const ContentBlock &block : assistant->blocks) {
const auto *existing = std::get_if<FileEditBlock>(&block);
if (existing && existing->id == editId)
return true;
}
return false;
};
struct RecordedAgentEdit
{
QString editId;
QString filePath;
QString oldContent;
QString newContent;
};
const auto appendAgentEdits =
[assistant, &hasFileEdit](const ToolCallBlock &tool) -> QList<RecordedAgentEdit> {
QList<RecordedAgentEdit> recorded;
if (tool.status != QLatin1String("completed"))
return recorded;
const QJsonArray diffs = tool.details.value("diffs").toArray();
for (qsizetype i = 0; i < diffs.size(); ++i) {
const QJsonObject diff = diffs.at(i).toObject();
const QString path = diff.value("path").toString();
const QString oldText = diff.value("oldText").toString();
const QString newText = diff.value("newText").toString();
if (path.isEmpty() || (oldText.isEmpty() && newText.isEmpty()))
continue;
const QString editId = QString("agent-%1-%2").arg(tool.id).arg(i);
if (hasFileEdit(editId))
continue;
const QJsonObject payload{
{"edit_id", editId},
{"file", path},
{"old_content", oldText},
{"new_content", newText},
{"status", "applied"},
{"status_message", "Applied by agent"}};
assistant->blocks.append(
FileEditBlock{.id = editId, .payload = encodeFileEditPayload(payload)});
const bool revertable = !newText.isEmpty()
&& !diff.value("truncated").toBool(false);
if (revertable)
recorded.append({editId, path, oldText, newText});
}
return recorded;
};
for (auto it = assistant->blocks.rbegin(); it != assistant->blocks.rend(); ++it) {
auto *tool = std::get_if<ToolCallBlock>(&*it);
if (!tool || tool->id != update.toolId)
continue;
if (!update.name.isEmpty())
tool->name = update.name;
if (!update.kind.isEmpty())
tool->kind = update.kind;
if (!update.status.isEmpty())
tool->status = update.status;
if (!update.arguments.isEmpty())
tool->arguments = update.arguments;
if (!update.details.isEmpty())
tool->details = update.details;
if (!update.result.isEmpty())
tool->result = update.result;
const bool agentTool = tool->fromAgent;
if (!agentTool) {
if (const auto edit = fileEditFromToolResult(update.result);
edit && !hasFileEdit(edit->id)) {
m_textSegment.clear();
assistant->blocks.append(*edit);
syncAssistantRows();
return;
}
} else {
const ToolCallBlock snapshot = *tool;
const qsizetype blocksBefore = assistant->blocks.size();
const QList<RecordedAgentEdit> recorded = appendAgentEdits(snapshot);
if (assistant->blocks.size() != blocksBefore) {
m_textSegment.clear();
syncAssistantRows();
for (const RecordedAgentEdit &edit : recorded) {
emit agentFileEditRecorded(
m_activeTurnId,
edit.editId,
edit.filePath,
edit.oldContent,
edit.newContent);
}
return;
}
}
const RowKind rowKind = agentTool ? RowKind::AgentTool : RowKind::Tool;
const QString rowId = update.toolId;
if (!refreshAssistantBlockRow(*it, rowKind, rowId))
syncAssistantRows();
return;
}
m_textSegment.clear();
if (update.dropPrecedingText && !assistant->blocks.isEmpty()
&& std::get_if<TextBlock>(&assistant->blocks.last())) {
assistant->blocks.removeLast();
}
const ToolCallBlock appended{
.id = update.toolId,
.name = update.name,
.arguments = update.arguments,
.result = update.result,
.kind = update.kind,
.status = update.status,
.details = update.details,
.fromAgent = update.fromAgent};
assistant->blocks.append(appended);
QList<RecordedAgentEdit> recorded;
if (update.fromAgent) {
recorded = appendAgentEdits(appended);
} else if (const auto edit = fileEditFromToolResult(update.result)) {
assistant->blocks.append(*edit);
}
syncAssistantRows();
for (const RecordedAgentEdit &edit : recorded) {
emit agentFileEditRecorded(
m_activeTurnId, edit.editId, edit.filePath, edit.oldContent, edit.newContent);
}
}
void Session::applyAgentPlan(const PlanUpdated &plan)
{
ensureAssistantMessage();
Message *assistant = activeAssistantMessage();
if (!assistant)
return;
for (ContentBlock &block : assistant->blocks) {
auto *existing = std::get_if<PlanBlock>(&block);
if (!existing)
continue;
existing->entries = plan.entries;
if (!refreshAssistantBlockRow(block, RowKind::Plan, assistant->id))
syncAssistantRows();
return;
}
if (plan.entries.isEmpty())
return;
m_textSegment.clear();
assistant->blocks.append(PlanBlock{plan.entries});
syncAssistantRows();
}
void Session::applyPermissionRequest(const PermissionRequested &request)
{
const QString autoOptionId = autoAnswerOptionFor(request);
m_textSegment.clear();
m_pendingPermissions.insert(request.requestId);
mutateAssistant([&request, &autoOptionId](Message &message) {
message.blocks.append(
PermissionBlock{
.requestId = request.requestId,
.toolCallId = request.toolCallId,
.title = request.title,
.toolKind = request.toolKind,
.options = request.options,
.status = PermissionStatus::Pending,
.selectedOptionId = {},
.automatic = !autoOptionId.isEmpty()});
});
if (autoOptionId.isEmpty())
return;
if (m_backend)
m_backend->respondPermission(request.requestId, autoOptionId);
else
applyPermissionResolution(
PermissionResolved{
.turnId = request.turnId, .requestId = request.requestId, .cancelled = true});
}
void Session::applyPermissionResolution(const PermissionResolved &resolution)
{
if (!m_pendingPermissions.remove(resolution.requestId))
return;
QString grantedToolKind;
bool grantAllows = false;
mutatePermissionBlock(resolution.requestId, [&](PermissionBlock &block) {
block.status = resolution.cancelled ? PermissionStatus::Cancelled
: PermissionStatus::Answered;
block.selectedOptionId = resolution.cancelled ? QString() : resolution.optionId;
if (resolution.cancelled) {
block.automatic = false;
return;
}
const std::optional<PermissionOption> option = block.option(resolution.optionId);
if (option && option->scopesToConversation() && !block.toolKind.isEmpty()) {
grantedToolKind = block.toolKind;
grantAllows = option->allows();
}
});
if (grantedToolKind.isEmpty())
return;
if (grantAllows)
m_alwaysAllowedToolKinds.insert(grantedToolKind);
else
m_alwaysRejectedToolKinds.insert(grantedToolKind);
}
void Session::mutatePermissionBlock(
const QString &requestId, const std::function<void(PermissionBlock &)> &mutate)
{
PermissionBlock *target = nullptr;
m_history.visitBlocks([&](ContentBlock &block) {
if (auto *permission = std::get_if<PermissionBlock>(&block);
permission && permission->requestId == requestId) {
target = permission;
}
});
if (!target)
return;
mutate(*target);
const QString encodedPayload = encodePermissionBlock(*target);
for (int i = static_cast<int>(m_rows.size()) - 1; i >= 0; --i) {
if (m_rows[i].kind != RowKind::Permission || m_rows[i].id != requestId)
continue;
m_rows[i].content = encodedPayload;
const MessageRow updated = m_rows.at(i);
emit rowUpdated(i, updated);
return;
}
}
bool Session::updateFileEditStatus(
const QString &editId, const QString &status, const QString &statusMessage)
{
@@ -202,7 +577,7 @@ bool Session::updateFileEditStatus(
void Session::handleEvent(const SessionEvent &event)
{
static_assert(
std::variant_size_v<SessionEvent> == 8,
std::variant_size_v<SessionEvent> == 11,
"SessionEvent gained an alternative; extend the dispatch below or it is silently dropped");
if (const auto *started = std::get_if<TurnStarted>(&event)) {
@@ -212,6 +587,19 @@ void Session::handleEvent(const SessionEvent &event)
return;
}
if (const auto *resolution = std::get_if<PermissionResolved>(&event)) {
applyPermissionResolution(*resolution);
return;
}
if (const auto *info = std::get_if<SessionInfo>(&event)) {
constexpr qsizetype maxSessionTitleLength = 60;
const QString title = info->title.simplified().left(maxSessionTitleLength);
if (!title.isEmpty())
emit sessionInfoReceived(title);
return;
}
if (const auto *failure = std::get_if<TurnFailed>(&event)) {
if (!failure->turnId.isEmpty() && failure->turnId != m_activeTurnId)
return;
@@ -265,34 +653,12 @@ void Session::handleEvent(const SessionEvent &event)
.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 *toolUpdate = std::get_if<ToolCallUpdated>(&event)) {
applyToolCall(*toolUpdate);
} else if (const auto *plan = std::get_if<PlanUpdated>(&event)) {
applyAgentPlan(*plan);
} else if (const auto *permission = std::get_if<PermissionRequested>(&event)) {
applyPermissionRequest(*permission);
} else if (const auto *usage = std::get_if<UsageReported>(&event)) {
if (Message *assistant = activeAssistantMessage()) {
assistant->usage = usage->usage;
@@ -419,6 +785,27 @@ void Session::syncAssistantRows()
void Session::endTurn()
{
const QStringList unanswered(m_pendingPermissions.cbegin(), m_pendingPermissions.cend());
for (const QString &requestId : unanswered) {
qWarning("QodeAssist: permission request %s outlived its turn and was declined",
qUtf8Printable(requestId));
applyPermissionResolution(
PermissionResolved{.turnId = m_activeTurnId, .requestId = requestId, .cancelled = true});
}
if (Message *assistant = activeAssistantMessage()) {
bool interrupted = false;
for (ContentBlock &block : assistant->blocks) {
auto *tool = std::get_if<ToolCallBlock>(&block);
if (!tool || isTerminalToolStatus(tool->status))
continue;
tool->status = QStringLiteral("interrupted");
interrupted = true;
}
if (interrupted)
syncAssistantRows();
}
m_activeTurnId.clear();
m_textSegment.clear();
m_assistantRowStart = -1;

View File

@@ -7,6 +7,7 @@
#include <QList>
#include <QObject>
#include <QPointer>
#include <QSet>
#include <QString>
#include <functional>
@@ -38,11 +39,12 @@ public:
void truncateRows(int rowIndex);
void sendTurn(
const QList<ContentBlock> &userBlocks,
const std::optional<TurnContext> &context,
const TurnOptions &options);
const QList<ContentBlock> &userBlocks, const std::optional<TurnContext> &context);
void cancel();
void respondPermission(const QString &requestId, const QString &optionId);
bool isPermissionPending(const QString &requestId) const;
bool updateFileEditStatus(
const QString &editId, const QString &status, const QString &statusMessage = {});
@@ -55,9 +57,25 @@ signals:
void turnFinished(const QString &turnId);
void turnFailed(const QString &error);
void usageReceived(const QodeAssist::Session::Usage &usage);
void sessionInfoReceived(const QString &title);
void agentFileEditRecorded(
const QString &turnId,
const QString &editId,
const QString &filePath,
const QString &oldContent,
const QString &newContent);
private:
void handleEvent(const SessionEvent &event);
void applyToolCall(const ToolCallUpdated &update);
void applyAgentPlan(const PlanUpdated &plan);
bool refreshAssistantBlockRow(const ContentBlock &block, RowKind kind, const QString &rowId);
void applyPermissionRequest(const PermissionRequested &request);
void applyPermissionResolution(const PermissionResolved &resolution);
void mutatePermissionBlock(
const QString &requestId, const std::function<void(PermissionBlock &)> &mutate);
std::optional<PermissionBlock> permissionBlock(const QString &requestId) const;
QString autoAnswerOptionFor(const PermissionRequested &request) const;
void appendMessage(const Message &message);
Message *activeAssistantMessage();
void mutateAssistant(const std::function<void(Message &)> &mutate);
@@ -73,6 +91,9 @@ private:
QString m_activeTurnId;
QString m_textSegment;
int m_assistantRowStart = -1;
QSet<QString> m_pendingPermissions;
QSet<QString> m_alwaysAllowedToolKinds;
QSet<QString> m_alwaysRejectedToolKinds;
};
} // namespace QodeAssist::Session

View File

@@ -8,7 +8,14 @@ namespace QodeAssist::Session {
QString turnIdOf(const SessionEvent &event)
{
return std::visit([](const auto &alternative) { return alternative.turnId; }, event);
return std::visit(
[](const auto &alternative) -> QString {
if constexpr (requires { alternative.turnId; })
return alternative.turnId;
else
return {};
},
event);
}
} // namespace QodeAssist::Session

View File

@@ -10,7 +10,9 @@
#include <variant>
#include "session/AgentPlan.hpp"
#include "session/Message.hpp"
#include "session/PermissionRequest.hpp"
namespace QodeAssist::Session {
@@ -39,25 +41,50 @@ struct ThinkingReceived
bool operator==(const ThinkingReceived &other) const = default;
};
struct ToolCallStarted
struct ToolCallUpdated
{
QString turnId;
QString toolId;
QString name;
QString kind;
QString status;
QJsonObject arguments;
QString result;
QJsonObject details;
bool dropPrecedingText = false;
bool fromAgent = false;
bool operator==(const ToolCallStarted &other) const = default;
bool operator==(const ToolCallUpdated &other) const = default;
};
struct ToolCallCompleted
struct PlanUpdated
{
QString turnId;
QString toolId;
QString name;
QString result;
QList<PlanEntry> entries;
bool operator==(const ToolCallCompleted &other) const = default;
bool operator==(const PlanUpdated &other) const = default;
};
struct PermissionRequested
{
QString turnId;
QString requestId;
QString toolCallId;
QString title;
QString toolKind;
QList<PermissionOption> options;
bool operator==(const PermissionRequested &other) const = default;
};
struct PermissionResolved
{
QString turnId;
QString requestId;
QString optionId;
bool cancelled = false;
bool operator==(const PermissionResolved &other) const = default;
};
struct UsageReported
@@ -68,6 +95,13 @@ struct UsageReported
bool operator==(const UsageReported &other) const = default;
};
struct SessionInfo
{
QString title;
bool operator==(const SessionInfo &other) const = default;
};
struct TurnCompleted
{
QString turnId;
@@ -87,9 +121,12 @@ using SessionEvent = std::variant<
TurnStarted,
TextDelta,
ThinkingReceived,
ToolCallStarted,
ToolCallCompleted,
ToolCallUpdated,
PlanUpdated,
PermissionRequested,
PermissionResolved,
UsageReported,
SessionInfo,
TurnCompleted,
TurnFailed>;

View File

@@ -10,9 +10,6 @@ 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(
@@ -28,9 +25,6 @@ QString renderSystemPrompt(const TurnContext &context)
"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");
}
@@ -44,12 +38,6 @@ QString renderSystemPrompt(const TurnContext &context)
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;
}

View File

@@ -29,25 +29,20 @@ struct InvokedSkill
bool operator==(const InvokedSkill &other) const = default;
};
struct LinkedFile
struct TurnContextNeeds
{
QString fileName;
QString content;
bool systemPrompt = true;
bool operator==(const LinkedFile &other) const = default;
bool operator==(const TurnContextNeeds &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;
};

View File

@@ -10,25 +10,18 @@
namespace QodeAssist::Session {
TurnContextBuilder::TurnContextBuilder(
const IProjectContextPort &project,
const ISkillsContextPort *skills,
const ILinkedFilesPort &linkedFiles)
const IProjectContextPort &project, const ISkillsContextPort *skills)
: 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) {
if (m_skills && request.needs.systemPrompt) {
context.alwaysOnSkills = m_skills->alwaysOnBodies();
context.skillsCatalog = m_skills->catalogText();
@@ -48,10 +41,6 @@ TurnContext TurnContextBuilder::build(const TurnContextRequest &request) const
}
}
context.linkedFilePaths = request.linkedFilePaths;
if (!request.linkedFilePaths.isEmpty())
context.linkedFiles = m_linkedFiles.readFiles(request.linkedFilePaths);
return context;
}

View File

@@ -4,11 +4,8 @@
#pragma once
#include <QList>
#include <QString>
#include <optional>
#include "session/TurnContext.hpp"
#include "session/TurnContextPorts.hpp"
@@ -18,29 +15,21 @@ struct TurnContextRequest
{
QString message;
QString basePrompt;
std::optional<QString> rolePrompt;
QList<QString> linkedFilePaths;
TurnContextNeeds needs;
};
class TurnContextBuilder
{
public:
TurnContextBuilder(
const IProjectContextPort &project,
const ISkillsContextPort *skills,
const ILinkedFilesPort &linkedFiles);
TurnContextBuilder(const IProjectContextPort &project, const ISkillsContextPort *skills);
TurnContextBuilder(IProjectContextPort &&, const ISkillsContextPort *, const ILinkedFilesPort &)
= delete;
TurnContextBuilder(const IProjectContextPort &, const ISkillsContextPort *, ILinkedFilesPort &&)
= delete;
TurnContextBuilder(IProjectContextPort &&, const ISkillsContextPort *) = 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

@@ -23,7 +23,6 @@ public:
virtual ~IProjectContextPort() = default;
virtual ProjectInfo projectInfo() const = 0;
virtual QString projectRules() const = 0;
};
class ISkillsContextPort
@@ -39,15 +38,4 @@ public:
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,103 @@
// 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/TurnLedger.hpp"
#include <QUuid>
#include <utility>
namespace QodeAssist::Session {
TurnLedger::~TurnLedger()
{
drainPermissions();
}
QString TurnLedger::beginTurn(const QString &turnId)
{
m_activeTurnId = turnId;
return m_activeTurnId;
}
QStringList TurnLedger::endTurn()
{
m_activeTurnId.clear();
return drainPermissions();
}
QString TurnLedger::activeTurnId() const
{
return m_activeTurnId;
}
bool TurnLedger::hasActiveTurn() const
{
return !m_activeTurnId.isEmpty();
}
bool TurnLedger::isActiveTurn(const QString &turnId) const
{
return !m_activeTurnId.isEmpty() && turnId == m_activeTurnId;
}
QString TurnLedger::registerPermission(PermissionResolver resolve, PermissionCanceller cancel)
{
const QString requestId
= QStringLiteral("perm-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces));
m_pending.insert(requestId, PendingPermission{std::move(resolve), std::move(cancel)});
return requestId;
}
bool TurnLedger::resolvePermission(const QString &requestId, const QString &optionId)
{
const auto it = m_pending.constFind(requestId);
if (it == m_pending.constEnd())
return false;
const PendingPermission pending = it.value();
m_pending.erase(it);
if (pending.resolve)
pending.resolve(optionId);
return true;
}
bool TurnLedger::cancelPermission(const QString &requestId)
{
const auto it = m_pending.constFind(requestId);
if (it == m_pending.constEnd())
return false;
const PendingPermission pending = it.value();
m_pending.erase(it);
if (pending.cancel)
pending.cancel();
return true;
}
QStringList TurnLedger::drainPermissions()
{
const QHash<QString, PendingPermission> pending = std::exchange(m_pending, {});
for (const PendingPermission &entry : pending) {
if (entry.cancel)
entry.cancel();
}
return pending.keys();
}
bool TurnLedger::hasPendingPermission(const QString &requestId) const
{
return m_pending.contains(requestId);
}
int TurnLedger::pendingPermissionCount() const
{
return static_cast<int>(m_pending.size());
}
} // namespace QodeAssist::Session

View File

@@ -0,0 +1,50 @@
// 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 <QHash>
#include <QString>
#include <QStringList>
#include <functional>
namespace QodeAssist::Session {
class TurnLedger
{
public:
using PermissionResolver = std::function<void(const QString &optionId)>;
using PermissionCanceller = std::function<void()>;
TurnLedger() = default;
~TurnLedger();
TurnLedger(const TurnLedger &) = delete;
TurnLedger &operator=(const TurnLedger &) = delete;
QString beginTurn(const QString &turnId);
QStringList endTurn();
QString activeTurnId() const;
bool hasActiveTurn() const;
bool isActiveTurn(const QString &turnId) const;
QString registerPermission(PermissionResolver resolve, PermissionCanceller cancel);
bool resolvePermission(const QString &requestId, const QString &optionId);
bool cancelPermission(const QString &requestId);
QStringList drainPermissions();
bool hasPendingPermission(const QString &requestId) const;
int pendingPermissionCount() const;
private:
struct PendingPermission
{
PermissionResolver resolve;
PermissionCanceller cancel;
};
QString m_activeTurnId;
QHash<QString, PendingPermission> m_pending;
};
} // namespace QodeAssist::Session

View File

@@ -14,20 +14,11 @@
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