// Copyright (C) 2026 Petr Mironychev // SPDX-License-Identifier: GPL-3.0-or-later // Additional attribution terms under GPLv3 §7(b) apply — see LICENSE #include "ChatHistorySerializerTest.hpp" #include #include #include #include "ChatView/ChatModel.hpp" #include "SessionTestSupport.hpp" #include "session/HistoryProjection.hpp" #include "session/HistorySerializer.hpp" #include "session/Session.hpp" namespace QodeAssist { void ChatHistorySerializerTest::testHistorySnapshotUsesCurrentVersion() { const QJsonObject root = Session::HistorySerializer::toJson(sampleHistory()); QCOMPARE(root.value("version").toString(), Session::HistorySerializer::currentVersion()); QCOMPARE(root.value("messages").toArray().size(), 2); } void ChatHistorySerializerTest::testHistorySnapshotRoundTrip() { const Session::ConversationHistory history = sampleHistory(); const auto restored = Session::HistorySerializer::fromJson( Session::HistorySerializer::toJson(history)); QVERIFY(restored.has_value()); QCOMPARE(restored->size(), history.size()); QCOMPARE(restored->at(1).blocks.size(), history.at(1).blocks.size()); QCOMPARE(*restored, history); } void ChatHistorySerializerTest::testUnsupportedChatVersionIsRejected() { QJsonObject root; root["version"] = "9.9"; root["messages"] = QJsonArray{}; QVERIFY(!Session::HistorySerializer::fromJson(root).has_value()); QVERIFY(!Session::HistorySerializer::isSupportedVersion("9.9")); QVERIFY(Session::HistorySerializer::isSupportedVersion("0.1")); QVERIFY(Session::HistorySerializer::isSupportedVersion("0.2")); } void ChatHistorySerializerTest::testChatFileWithoutMessagesArrayIsRejected() { QJsonObject missing; missing["version"] = Session::HistorySerializer::currentVersion(); QVERIFY(!Session::HistorySerializer::fromJson(missing).has_value()); QJsonObject wrongType; wrongType["version"] = Session::HistorySerializer::currentVersion(); wrongType["messages"] = "not an array"; QVERIFY(!Session::HistorySerializer::fromJson(wrongType).has_value()); QJsonObject empty; empty["version"] = Session::HistorySerializer::currentVersion(); empty["messages"] = QJsonArray{}; const auto history = Session::HistorySerializer::fromJson(empty); QVERIFY(history.has_value()); QCOMPARE(history->size(), 0); } void ChatHistorySerializerTest::testLegacyChatFileConvertsToHistory() { QJsonObject root; root["version"] = "0.2"; root["messages"] = QJsonArray{ QJsonObject{{"role", 1}, {"content", "explain this"}, {"id", "u1"}}, QJsonObject{ {"role", 5}, {"content", "weighing options\n[Signature: sig-1...]"}, {"id", "r1"}, {"signature", "sig-1"}, {"usage", QJsonObject{{"promptTokens", 120}, {"completionTokens", 40}}}}, QJsonObject{ {"role", 3}, {"content", "read_file\nint main() {}"}, {"id", "t1"}, {"toolName", "read_file"}, {"toolResult", "int main() {}"}}, QJsonObject{{"role", 2}, {"content", "here is the answer"}, {"id", "r1"}}}; const auto history = Session::HistorySerializer::fromJson(root); QVERIFY(history.has_value()); QCOMPARE(history->size(), 2); const Session::Message &user = history->at(0); QCOMPARE(user.role, Session::MessageRole::User); QCOMPARE(user.blocks.size(), 1); QCOMPARE(std::get(user.blocks.at(0)), (Session::TextBlock{"explain this"})); const Session::Message &assistant = history->at(1); QCOMPARE(assistant.role, Session::MessageRole::Assistant); QCOMPARE(assistant.id, QString("r1")); QCOMPARE(assistant.blocks.size(), 3); QCOMPARE( std::get(assistant.blocks.at(0)), (Session::ThinkingBlock{"weighing options", "sig-1", false})); QCOMPARE( std::get(assistant.blocks.at(1)), (Session::ToolCallBlock{"t1", "read_file", QJsonObject{}, "int main() {}"})); QCOMPARE( std::get(assistant.blocks.at(2)), (Session::TextBlock{"here is the answer"})); QCOMPARE(assistant.usage.promptTokens, 120); QCOMPARE(assistant.usage.completionTokens, 40); } void ChatHistorySerializerTest::testLegacyToolRowWithoutMetadataKeepsItsText() { QJsonObject root; root["version"] = "0.2"; root["messages"] = QJsonArray{ QJsonObject{{"role", 1}, {"content", "list the files"}, {"id", "u1"}}, QJsonObject{{"role", 3}, {"content", "main.cpp\nmain.qml"}, {"id", "t1"}}}; const auto history = Session::HistorySerializer::fromJson(root); QVERIFY(history.has_value()); const QList rows = Session::projectToRows(*history); QCOMPARE(rows.size(), 2); QCOMPARE(rows.at(1).kind, Session::RowKind::Tool); QCOMPARE(rows.at(1).id, QString("t1")); QCOMPARE(rows.at(1).content, QString("main.cpp\nmain.qml")); } void ChatHistorySerializerTest::testChatRowsRoundTripThroughHistory() { Session::MessageRow user; user.kind = Session::RowKind::User; user.id = "u1"; user.content = "explain this"; user.attachments = {Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}}; user.images = {Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}; Session::MessageRow thinking; thinking.kind = Session::RowKind::Thinking; thinking.id = "r1"; thinking.content = "weighing options\n[Signature: sig-1...]"; thinking.signature = "sig-1"; thinking.usage = Session::Usage{120, 40, 80, 12}; Session::MessageRow tool; tool.kind = Session::RowKind::Tool; tool.id = "t1"; tool.content = "read_file\nint main() {}"; tool.toolName = "read_file"; tool.toolArguments = QJsonObject{{"path", "main.cpp"}}; tool.toolResult = "int main() {}"; Session::MessageRow assistant; assistant.kind = Session::RowKind::Assistant; assistant.id = "r1"; assistant.content = "here is the answer"; Session::MessageRow fileEdit; fileEdit.kind = Session::RowKind::FileEdit; fileEdit.id = "e1"; fileEdit.content = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\"}"; const QList rows{user, thinking, tool, assistant, fileEdit}; const Session::ConversationHistory history = Session::buildFromRows(rows); QCOMPARE(history.size(), 2); const QList projected = Session::projectToRows(history); QCOMPARE(projected, rows); } void ChatHistorySerializerTest::testCompressedChatShapeReloadsAsOneAssistantRow() { Session::Message summary; summary.role = Session::MessageRole::Assistant; summary.id = "c1"; summary.blocks = {Session::TextBlock{"# Chat Summary\n\nwe discussed the parser"}}; Session::ConversationHistory compressed; compressed.append(summary); const auto reloaded = Session::HistorySerializer::fromJson( Session::HistorySerializer::toJson(compressed)); QVERIFY(reloaded.has_value()); const QList rows = Session::projectToRows(*reloaded); QCOMPARE(rows.size(), 1); QCOMPARE(rows.at(0).kind, Session::RowKind::Assistant); QCOMPARE(rows.at(0).content, QString("# Chat Summary\n\nwe discussed the parser")); } void ChatHistorySerializerTest::testHistoryAppliesToChatModel() { Chat::ChatModel model; Session::Session session; attachModelToSession(session, model); session.setHistory(historyWithoutFileEdits()); QCOMPARE(model.rowCount(), 4); QCOMPARE( model.data(model.index(0), Chat::ChatModel::RoleType).value(), Chat::ChatModel::ChatRole::User); QCOMPARE(model.data(model.index(0), Chat::ChatModel::Content).toString(), QString("explain this")); QCOMPARE(model.data(model.index(0), Chat::ChatModel::Attachments).toList().size(), 1); QCOMPARE(model.data(model.index(0), Chat::ChatModel::Images).toList().size(), 1); QCOMPARE( model.data(model.index(1), Chat::ChatModel::RoleType).value(), Chat::ChatModel::ChatRole::Thinking); QCOMPARE( model.data(model.index(1), Chat::ChatModel::Content).toString(), QString("weighing options\n[Signature: sig-1...]")); QCOMPARE( model.data(model.index(2), Chat::ChatModel::RoleType).value(), Chat::ChatModel::ChatRole::Tool); QCOMPARE( model.data(model.index(2), Chat::ChatModel::Content).toString(), QString("read_file\nint main() {}")); QCOMPARE( model.data(model.index(3), Chat::ChatModel::RoleType).value(), Chat::ChatModel::ChatRole::Assistant); QCOMPARE( model.data(model.index(3), Chat::ChatModel::Content).toString(), QString("here is the answer")); QCOMPARE(model.sessionPromptTokens(), 120); QCOMPARE(model.sessionCompletionTokens(), 40); QCOMPARE(model.sessionCachedPromptTokens(), 80); } void ChatHistorySerializerTest::testAdjacentThinkingBlocksSurviveReload() { Session::Message assistant; assistant.role = Session::MessageRole::Assistant; assistant.id = "r1"; assistant.blocks = {Session::ThinkingBlock{"first", "sig-a", true}, Session::ThinkingBlock{"second", "sig-b", true}, Session::TextBlock{"the answer"}}; Session::ConversationHistory history; history.append(assistant); const QList rows = Session::projectToRows(history); QCOMPARE(rows.size(), 3); QCOMPARE(rows.at(0).content, QString("first\n[Signature: sig-a...]")); QCOMPARE(rows.at(1).content, QString("second\n[Signature: sig-b...]")); QCOMPARE(Session::buildFromRows(rows), history); } } // namespace QodeAssist