From cfcb1e70d30a5b4f11aa6cad506c86ae605b0d9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 06:19:04 +0000 Subject: [PATCH] Treat unreadable TUI session JSON as non-empty session_json_is_empty treated json_parse failures (and non-object blobs) as empty transcripts, so Untitled 24h GC hard-deleted them and create_or_reuse overwrote the stored session. Fail closed: only a true empty object (or empty string) is empty. Co-authored-by: Tyler Reckart --- CHANGELOG.md | 6 +++ src/repl/conversation_store.cpp | 8 +++- tests/test_conversation_store.cpp | 69 +++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dae12b43..239ca03d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] +### Fixed +- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no + longer treats a JSON parse error (or a non-object blob) as an empty + transcript. Untitled 24h GC and `create_or_reuse` were hard-deleting + or overwriting sessions that could not be parsed. + ## [0.13.4] — 2026-09-10 ### Fixed diff --git a/src/repl/conversation_store.cpp b/src/repl/conversation_store.cpp index a514450f..6bf6bcaa 100644 --- a/src/repl/conversation_store.cpp +++ b/src/repl/conversation_store.cpp @@ -119,7 +119,10 @@ bool session_json_is_empty(const std::string& raw) { if (raw.empty()) return true; try { auto root = json_parse(raw); - if (!root || !root->is_object()) return true; + // Wrong shape is unreadable, not "no turns". Treating it as empty + // would let GC hard-delete Untitled rows and let create_or_reuse + // overwrite the blob. + if (!root || !root->is_object()) return false; auto idx = root->get("index"); if (idx && idx->is_array() && !idx->as_array().empty()) return false; auto agents = root->get("agents"); @@ -127,7 +130,8 @@ bool session_json_is_empty(const std::string& raw) { return false; return true; } catch (...) { - return true; + // Parse failure is the same class of unreadable blob — fail closed. + return false; } } diff --git a/tests/test_conversation_store.cpp b/tests/test_conversation_store.cpp index e2bf839a..21cc3d1d 100644 --- a/tests/test_conversation_store.cpp +++ b/tests/test_conversation_store.cpp @@ -227,6 +227,75 @@ TEST_CASE("create_or_reuse_for with empty folder_id unfiles a reused chat") { fs::remove_all(dir); } +TEST_CASE("create_or_reuse does not treat unreadable session JSON as empty") { + const std::string dir = make_temp_dir(); + ConversationStore store(dir); + + const std::string id = store.active_id(); + write_session(store, id, "{ this is not valid json"); + const std::string after = store.create_or_reuse(dir); + CHECK(after != id); + CHECK(store.session_json(id).find("not valid json") != std::string::npos); + CHECK(store.list().size() == 2); + + // Canonical empty object still reuses (true empty, not corrupt). + write_session(store, after, + R"({"version":2,"index":[],"agents":{},"compaction":{}})"); + const std::string reused = store.create_or_reuse(dir); + CHECK(reused == after); + + fs::remove_all(dir); +} + +TEST_CASE("create_or_reuse does not treat a JSON array session as empty") { + const std::string dir = make_temp_dir(); + ConversationStore store(dir); + + const std::string id = store.active_id(); + write_session(store, id, R"([{"role":"user","content":"hi"}])"); + const std::string after = store.create_or_reuse(dir); + CHECK(after != id); + CHECK(store.session_json(id).find("hi") != std::string::npos); + + fs::remove_all(dir); +} + +TEST_CASE("GC keeps 24h+ Untitled conversations whose session JSON will not parse") { + const std::string dir = make_temp_dir(); + std::string keep_active; + std::string corrupt; + std::string truly_empty; + { + ConversationStore store(dir); + keep_active = store.active_id(); + corrupt = store.create(dir); + truly_empty = store.create(dir); + write_session(store, corrupt, "{ truncated"); + store.set_active(keep_active); + const std::int64_t old = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()) - 48 * 3600; + REQUIRE(store.tenant_store().set_conversation_timestamps( + store.tenant_id(), to_db_id(corrupt), old, old)); + REQUIRE(store.tenant_store().set_conversation_timestamps( + store.tenant_id(), to_db_id(truly_empty), old, old)); + } + + ConversationStore store(dir); + bool saw_corrupt = false; + bool saw_empty = false; + for (const auto& e : store.list()) { + if (e.id == corrupt) saw_corrupt = true; + if (e.id == truly_empty) saw_empty = true; + } + CHECK(saw_corrupt); + CHECK_FALSE(saw_empty); + CHECK(store.session_json(corrupt).find("truncated") != std::string::npos); + + fs::remove_all(dir); +} + TEST_CASE("create with a deleted folder id files as unfiled instead of throwing") { const std::string dir = make_temp_dir(); ConversationStore store(dir);