From 32453d3946a868faea75f891008a6cf3eacfb7ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 06:20:52 +0000 Subject: [PATCH] Skip non-object rows when decoding session message JSON decode_messages_json only skipped null shared_ptrs, so JSON null, strings, numbers, and arrays in a session history became empty-role Message rows on TUI restore. Skip any element that is not a JSON object, matching tool_trace_from_json. Co-authored-by: Tyler Reckart --- CHANGELOG.md | 7 +++++++ CMakeLists.txt | 13 ++++++++++++ src/message_codec.cpp | 9 ++++++++- tests/test_message_codec.cpp | 39 ++++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/test_message_codec.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index dae12b43..af883b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] +### Fixed +- **Session restore skips non-object message rows.** `decode_messages_json` + only skipped null `shared_ptr`s, so JSON `null` / strings / numbers / + arrays in a session history became empty-`role` `Message` rows on TUI + restore. Skip any element that is not a JSON object, matching + `tool_trace_from_json`. + ## [0.13.4] — 2026-09-10 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index ee7a9662..508e6fbe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1145,6 +1145,19 @@ if(INDEX_BUILD_TESTS) endif() add_test(NAME unit_message_replay_roundtrip COMMAND unit_message_replay_roundtrip) + # Session JSON message array decode (no OpenTUI). + add_executable(unit_message_codec + tests/test_message_codec.cpp + src/message_codec.cpp + src/json.cpp + ) + target_include_directories(unit_message_codec PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/third_party/doctest + ${OPENSSL_INCLUDE_DIR} # message_codec.h → api_client.h → openssl/ssl.h + ) + add_test(NAME unit_message_codec COMMAND unit_message_codec) + # --- Unit tests for the natural-language schedule parser --- add_executable(unit_schedule_parser tests/test_schedule_parser.cpp diff --git a/src/message_codec.cpp b/src/message_codec.cpp index e5c05ac0..57fb87c6 100644 --- a/src/message_codec.cpp +++ b/src/message_codec.cpp @@ -66,7 +66,14 @@ std::vector decode_messages_json(const std::string& json) { auto root = json_parse(json); if (!root || !root->is_array()) return out; for (auto& v : root->as_array()) { - if (!v) continue; + // get_string() on a non-object is a silent empty default, so a + // JSON null / string / number / array would otherwise become a + // Message with empty role. Those rows then go back into the + // model view on TUI/session restore and providers reject them. + // Skip the same way tool_trace_from_json already skips + // non-object trace entries. Null shared_ptr (sparse arrays) + // is the same class of invalid element. + if (!v || !v->is_object()) continue; Message msg; msg.role = v->get_string("role"); msg.content = v->get_string("content"); diff --git a/tests/test_message_codec.cpp b/tests/test_message_codec.cpp new file mode 100644 index 00000000..db80d115 --- /dev/null +++ b/tests/test_message_codec.cpp @@ -0,0 +1,39 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest.h" + +#include "message_codec.h" + +using namespace arbiter; + +TEST_CASE("decode_messages_json skips JSON null and other non-objects") { + const auto restored = decode_messages_json( + R"([null,"skip",1,true,[],{"role":"user","content":"keep"},{"role":"assistant","content":"also"}])"); + REQUIRE(restored.size() == 2); + CHECK(restored[0].role == "user"); + CHECK(restored[0].content == "keep"); + CHECK(restored[0].thinking.empty()); + CHECK(restored[0].tool_trace.empty()); + CHECK(restored[1].role == "assistant"); + CHECK(restored[1].content == "also"); +} + +TEST_CASE("decode_messages_json all-non-object array is empty, not empty-role rows") { + const auto restored = decode_messages_json(R"([null, "x", 0])"); + CHECK(restored.empty()); +} + +TEST_CASE("decode_messages_json still round-trips object rows") { + Message user; + user.role = "user"; + user.content = "do the thing"; + Message asst; + asst.role = "assistant"; + asst.content = "done"; + asst.thinking = "step"; + const auto back = decode_messages_json(encode_messages_json({user, asst})); + REQUIRE(back.size() == 2); + CHECK(back[0].role == "user"); + CHECK(back[0].content == "do the thing"); + CHECK(back[1].role == "assistant"); + CHECK(back[1].thinking == "step"); +}