mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-07-25 12:34:00 -04:00
feat: add support acp in common chat (#369)
This commit is contained in:
57
sources/session/AgentPlan.cpp
Normal file
57
sources/session/AgentPlan.cpp
Normal 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
|
||||
49
sources/session/AgentPlan.hpp
Normal file
49
sources/session/AgentPlan.hpp
Normal 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
|
||||
38
sources/session/BlockCodec.cpp
Normal file
38
sources/session/BlockCodec.cpp
Normal 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
|
||||
32
sources/session/BlockCodec.hpp
Normal file
32
sources/session/BlockCodec.hpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
36
sources/session/FencedText.cpp
Normal file
36
sources/session/FencedText.cpp
Normal 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
|
||||
13
sources/session/FencedText.hpp
Normal file
13
sources/session/FencedText.hpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
116
sources/session/PermissionRequest.cpp
Normal file
116
sources/session/PermissionRequest.cpp
Normal 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
|
||||
87
sources/session/PermissionRequest.hpp
Normal file
87
sources/session/PermissionRequest.hpp
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
103
sources/session/TurnLedger.cpp
Normal file
103
sources/session/TurnLedger.cpp
Normal 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
|
||||
50
sources/session/TurnLedger.hpp
Normal file
50
sources/session/TurnLedger.hpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user