Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
13 changes: 13 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/message_codec.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,14 @@ std::vector<Message> 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");
Expand Down
39 changes: 39 additions & 0 deletions tests/test_message_codec.cpp
Original file line number Diff line number Diff line change
@@ -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");
}
Loading