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
9 changes: 4 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ 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.
- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate`
mapped drain / `POST /v1/requests/:id/cancel` onto HTTP 401
`"missing or invalid bearer token"`, the same body as a disabled
tenant. Cancel now returns 409 `"request cancelled"`. Tenant
disable/rotate still 401 so clients cannot enumerate revoked
tenants.
- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string
- **Reconcile rollback no longer wipes the workspace on a failed restore.**
- **LaTeX math recursion depth.** `latex_math_to_plain` now stops converting
Expand Down
8 changes: 6 additions & 2 deletions src/repl/conversation_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,19 @@ 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");
if (agents && agents->is_object() && !agents->as_object().empty())
return false;
return true;
} catch (...) {
return true;
// Parse failure is the same class of unreadable blob — fail closed.
return false;
}
}

Expand Down
69 changes: 69 additions & 0 deletions tests/test_conversation_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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::int64_t>(
std::chrono::duration_cast<std::chrono::seconds>(
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);
Expand Down
Loading