diff --git a/CHANGELOG.md b/CHANGELOG.md index b427d2eb..d391b7ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed -- **Lesson search is a literal substring.** `search_lessons` (`GET /v1/lessons?q=` - and `/lesson search`) wrapped the query in `LIKE %q%` without escaping `%` / - `_`, so those characters acted as SQL wildcards. A search for `100%` matched - `1000`, and `foo_bar` matched `fooXbar`. Patterns now escape LIKE - metacharacters (`ESCAPE '\'`). +- **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`. - **Unreadable TUI sessions are not empty.** `session_json_is_empty` no - **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` - **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string @@ -34,6 +34,7 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). - **Loop `/kill` wakes the inter-iteration pause.** After each turn, - **Memory `tag=` LIKE wildcards are literals.** `/mem entries tag=` and - **Dispatch of stored agents past newest-200.** `GET /v1/agents/:id` already +- **Lesson search is a literal substring.** `search_lessons` (`GET /v1/lessons?q=` ## [0.13.7] — 2026-09-21 diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e0dda75..cee8d25c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1146,6 +1146,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"); +}