unsloth/studio/backend/tests/test_chat_history_storage.py
Lee Jackson 61ed4cac51
Studio: persist chat history in backend storage (#5272)
* feat: Persist chat history in backend storage

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address chat tombstone batching review

* fix: update desktop auth routes stub

* chat db settings storage

* chat db settings routes

* chat db settings client

* chat db settings store

* chat db settings wiring

* chat db history storage

* chat db settings migration

* chat db settings fallback

* chat db container metadata

* chat db legacy migration fixes

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* chat ci auth background reads

* chat auth storage fixes

* chat migration final fixes

* chat export batch message lookup

* chat history review fixes

* chat prune sync fix

* chat settings hydration retry

* gate settings persistence

* Scope chat-history rows by subject; fix hijack, clear-confirm, hydrate race

Backend storage and routes:
- chat_threads / chat_messages / chat_settings carry a NOT NULL subject
  column with composite PRIMARY KEY (id, subject). Two authenticated
  identities can no longer see or wipe each other's data.
- Pre-existing rows on an existing studio.db migrate under sentinel
  subject __legacy_unscoped__ via rename + rebuild + copy; single-user
  installs see no behavior change.
- ON CONFLICT(id, subject) DO UPDATE ... WHERE chat_messages.thread_id =
  excluded.thread_id refuses cross-thread re-parenting via upsert.
  upsert_chat_message + sync_chat_messages now raise
  ChatMessageThreadMismatch which the routes map to HTTP 409.
- replace_thread_messages rejects body messages whose threadId does not
  match the URL thread (HTTP 400) instead of silently rewriting them.
- DELETE /api/chat requires ?confirm=true, returns row count, logs the
  subject and count.
- upsert_chat_settings_merge does read + deep-merge + write inside a
  single BEGIN IMMEDIATE so concurrent writers no longer drop each
  other's updates. The route delegates to this helper.
- New POST /api/chat/messages:batch returns {thread_id -> messages[]}
  for many threads in one HTTP call. Subject-scoped. Unknown ids return
  empty lists instead of 404 so the sidebar/search caller can rebuild
  atomically.

Frontend:
- chat-runtime-store: hydrate-failure catch sets settingsHydrated:true
  so a transient backend blip no longer permanently disables
  persistence. setParams bumps inferenceParamMutationVersions
  unconditionally so a slow hydration response cannot clobber a
  pre-hydrate user edit. saveSettingsPatch replaces the serial chain
  with a debounced pendingPatch + deep merge; flush on beforeunload.
- chat-history-storage: clearStoredChats returns ClearStoredChatsResult
  distinguishing backend / legacy / both outcomes.
  listStoredChatThreadsWithMessages uses the batched fetch (one HTTP
  call) instead of Promise.all per-thread; legacy Dexie fallback only
  fires when the batch result is empty.
- chat-api: batchListChatMessages with graceful 404 / 405 fallback to
  per-thread listChatMessages for older servers.
- chat-thread-tombstones: store {id, deletedAt} tuples with 90-day GC
  and a 5000-entry cap so localStorage stays bounded. Back-compat reads
  pre-fix plain strings. Adds removeChatThreadTombstones (rollback) and
  clearAllChatThreadTombstones (post-legacy-purge clean-up).
- use-chat-sidebar-items: deleteChatItem tombstones synchronously
  BEFORE the backend round-trip and rolls back on failure (restores
  pre-PR optimistic UX). 300 ms trailing debounce on
  CHAT_HISTORY_UPDATED_EVENT plus requestSeq guard so stream-time event
  bursts produce at most one fetch per quiet window.

Tests:
- studio/backend/tests/pr5272_sim/ adds 64 regression tests covering
  schema migration from pre-fix shape, subject scoping, cross-thread
  hijack, bulk-replace mismatch, clear-confirm, concurrent settings,
  unicode + 2MB content + SQL-injection-safe binding, chunking
  boundary at 900 and 901 ids, batched endpoint (multi-subject + 1200
  ids + per-thread order), and grep contracts for the frontend patches.
  test_chat_history_storage.py updated to pass subject.

Verified locally on Linux + macOS + Windows GitHub Actions runners
(staging fork): 64 pass + 2 from the PR's own backend test on all
three OSes.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Drop subject scoping and clear-confirm gate (Studio is single-user)

Per maintainer feedback: subject scoping, cross-thread message hijack
guard, and DELETE /api/chat ?confirm=true gate are unnecessary because
Studio is intentionally single-user (the client already shows a confirm
dialog before clear-all).

This commit reverts those backend changes and keeps only the
non-multi-user pieces from the earlier fix commit:

- studio_db.py: restored to pre-fix shape; adds upsert_chat_settings_merge
  which does atomic read + deep-merge + write under BEGIN IMMEDIATE so
  two concurrent slider drags cannot drop one another's updates.
- routes/chat_history.py: restored; put_settings now calls the atomic
  merge instead of doing the read-merge-write across three separate
  connections. Adds POST /api/chat/messages:batch to collapse the
  sidebar/search rebuild from N round-trips to 1.
- frontend/api/chat-api.ts: align batchListChatMessages request and
  response keys with the backend (threadIds / messagesByThreadId).
- tests/test_chat_history_storage.py: add atomic-merge concurrency test,
  deep-merge nested-key test, and 901-id chunking-boundary test.
- Drop the pr5272_sim test directory (those tests covered the reverted
  subject-scoping/hijack/confirm behavior).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix sidebar delete crash, keepalive on settings beforeunload flush, search rebuild race

Two correctness bugs and one perf race surfaced by a fresh code review of
the prior fix commit:

- chat-api.ts: notifyChatHistoryUpdated was declared as a non-exported
  function, but use-chat-sidebar-items.ts imports it. The import would
  fail tsc with TS2305 and at runtime the optimistic-delete and
  delete-failure rollback paths would both throw.
- chat-runtime-store.ts + chat-settings-api.ts + chat-settings-storage.ts:
  the beforeunload settings flush is now actually keepalive. Without it
  the browser cancels the in-flight PUT on tab close, so the last slider
  drag is silently dropped (which is exactly the case the
  debounce+beforeunload combination was meant to protect against).
- use-chat-search-index.ts: rebuilds now coalesce with a 300ms trailing
  debounce and discard out-of-order responses via a requestSeq guard.
  Matches the sibling pattern in use-chat-sidebar-items.ts so two rapid
  CHAT_HISTORY_UPDATED_EVENTs (run-start + run-end save during a turn)
  cannot land with stale data winning.
- chat-thread-tombstones.ts: drop dead clearAllChatThreadTombstones with
  no call sites; Dexie is never wiped so the function has no use.

* fix(studio): protect chat persistence writes

* fix(studio): align chat history clear semantics

* fix(studio): show partial chat clear feedback

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(studio): preserve chat persistence fallbacks

* fix(studio): harden chat thread persistence checks

* Preserve chat message timestamps

* Gate chat stream on history save

* Make chat thread backfill best effort

* Avoid chat message 404 probe

* Tighten chat legacy fallbacks

* chat: server-side ledger so legacy Dexie import is recoverable

The boolean localStorage sentinel
(unsloth_chat_legacy_imported_to_studio_db) made importLegacyChatsIfNeeded
non-recoverable: deleting studio.db while the browser keeps the flag
silently hides every legacy Dexie thread from the sidebar (verified by
the 3-GPU validation probe; matches the third review comment on PR
#5272). Same trap fires for browser-profile sync to a fresh machine
and any other path that wipes studio.db while keeping IndexedDB.

Source of truth moves into studio.db itself via a new
chat_legacy_import_log table keyed by legacy thread id. The ledger
disappears together with studio.db, so the next launch re-runs the
import from whatever Dexie still holds. localStorage stays as a
per-session perf hint only.

Performance, all bounded by the three new fast-paths before any
backend work:

  A) localStorage hint says "imported earlier in this session" -- 0
     network, ~0 ms. Covers the warm sidebar mount.

  B) indexedDB.databases() reports no "unsloth-chat" DB -- 0 network,
     ~1 ms. Covers every new user who never had the old browser-only
     Studio (the common case after launch).

  C) db.threads.count() + db.messages.count() are both 0 -- 0 network,
     ~5 ms. Covers returning users who migrated long ago and Dexie was
     never repopulated.

Only when all three miss does the code talk to the backend
(GET /api/chat/import-ledger -> diff vs Dexie -> existing import path
-> POST /api/chat/import-ledger to record what was just imported).
Per-thread tracking is enough because Dexie is read-only after this
PR; a thread's message set does not grow.

Backend deployments that predate the import-ledger routes are
handled transparently: the client treats 404/405 as an empty ledger
and re-runs the (idempotent via UPSERT) import on next launch.

Changes:
- storage/studio_db.py: new chat_legacy_import_log table (WITHOUT
  ROWID, PK on legacy_thread_id) + list_chat_legacy_import_log() +
  record_chat_legacy_import_log() (idempotent batch UPSERT).
- routes/chat_history.py: GET + POST /api/chat/import-ledger with the
  obvious request/response models.
- frontend api/chat-api.ts: listChatImportLedger() (returns a Set for
  O(1) diff) + recordChatImportLedger(), both with 404/405 fallback.
- frontend utils/chat-history-storage.ts: importLegacyChatsIfNeeded
  gains three fast-paths, ledger fetch on the slow path, and writes
  the ledger after a successful import. The localStorage helper is
  unchanged on the surface; it just stops being authoritative.
- tests: 5 new test_legacy_import_log_* cases (empty default, record
  + list round-trip, idempotency, input dedup, empty/null ignore).
  All 9 pre-existing tests still pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make the legacy-import recovery actually recoverable

The previous commit added a server-side ledger to make Dexie -> studio.db
import recoverable after a studio.db wipe, but the localStorage perf hint
still short-circuited the import gate before the ledger was ever consulted.
After a wipe, the hint stayed "true" and the bulk re-import never ran -- the
ledger sat empty and only the per-thread lazy materialize-on-continue path
restored data.

Changes:

- Remove the localStorage short-circuit from importLegacyChatsIfNeeded so
  the ledger is checked on every fresh tab. legacyChatImportPromise keeps
  the per-session cache; the hint now only matters for the listing paths.
- Batch the slow path: one db.messages.where().anyOf().toArray() and one
  batchListChatMessages() instead of 2N round-trips. At 1k threads this
  drops a multi-second blocking import to a single request pair.
- recordChatImportLedger returns {accepted, inserted, supported}. The
  localStorage hint is only flipped when supported is true, so old
  backends (404 / 405 / 501) no longer permanently poison recovery.
- Ledger backfill: threads already present in chat_threads but missing
  from the ledger now get added too, so old-FE-then-new-FE deployments
  don't redo the diff every launch.
- Backend response field renamed recorded -> {accepted, inserted}.
  accepted is the deduped non-empty input count; inserted is the rows
  actually new (via INSERT ... RETURNING). Bounded by Field(max_length=
  10_000) on the request payload.
- Storage helpers renamed: chat_legacy_import_log -> chat_legacy_imports,
  record_* -> upsert_* to match the existing noun/verb conventions.
- DEXIE_DB_NAME exported from db.ts; duplicate constant in
  chat-history-storage.ts removed.
- 3 new route-level tests for /api/chat/import-ledger covering the
  round-trip, the (accepted, inserted) split, and the 10k payload cap.

All 18 chat-history tests pass.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: shine1i <wasimysdev@gmail.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-22 06:18:05 -07:00

308 lines
9.9 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import threading
import pytest
from storage import studio_db
def _reset_studio_db(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
def _thread(thread_id: str = "thread-1") -> dict:
return {
"id": thread_id,
"title": "Test Chat",
"modelType": "base",
"modelId": "test-model",
"pairId": None,
"archived": False,
"createdAt": 1_700_000_000_000,
}
def _message(
message_id: str,
created_at: int,
content: str,
thread_id: str = "thread-1",
) -> dict:
return {
"id": message_id,
"threadId": thread_id,
"parentId": None,
"role": "user",
"content": [{"type": "text", "text": content}],
"createdAt": created_at,
}
def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread())
studio_db.sync_chat_messages(
"thread-1",
[
_message("msg-1", 1, "keep me"),
_message("msg-2", 2, "old text"),
],
prune_missing = True,
)
messages = studio_db.sync_chat_messages(
"thread-1",
[_message("msg-2", 2, "updated text")],
)
by_id = {message["id"]: message for message in messages}
assert set(by_id) == {"msg-1", "msg-2"}
assert by_id["msg-2"]["content"] == [{"type": "text", "text": "updated text"}]
def test_sync_chat_messages_prunes_when_requested(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread())
studio_db.sync_chat_messages(
"thread-1",
[
_message("msg-1", 1, "delete me"),
_message("msg-2", 2, "keep me"),
],
)
messages = studio_db.sync_chat_messages(
"thread-1",
[_message("msg-2", 2, "keep me")],
prune_missing = True,
)
assert [message["id"] for message in messages] == ["msg-2"]
def test_upsert_chat_message_rejects_cross_thread_id_conflict(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread("thread-1"))
studio_db.upsert_chat_thread(_thread("thread-2"))
studio_db.upsert_chat_message(_message("msg-1", 1, "original", "thread-1"))
with pytest.raises(studio_db.ChatMessageConflictError):
studio_db.upsert_chat_message(_message("msg-1", 2, "moved", "thread-2"))
assert [m["id"] for m in studio_db.list_chat_messages("thread-1")] == ["msg-1"]
assert studio_db.list_chat_messages("thread-2") == []
def test_sync_chat_messages_detects_conflict_before_prune(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread("thread-1"))
studio_db.upsert_chat_thread(_thread("thread-2"))
studio_db.sync_chat_messages(
"thread-1",
[_message("keep-me", 1, "keep", "thread-1")],
)
studio_db.upsert_chat_message(_message("conflict", 2, "other", "thread-2"))
with pytest.raises(studio_db.ChatMessageConflictError):
studio_db.sync_chat_messages(
"thread-1",
[_message("conflict", 3, "bad", "thread-1")],
prune_missing = True,
)
assert [m["id"] for m in studio_db.list_chat_messages("thread-1")] == ["keep-me"]
assert [m["id"] for m in studio_db.list_chat_messages("thread-2")] == ["conflict"]
def test_settings_merge_atomic_under_concurrency(tmp_path, monkeypatch):
"""Two threads writing distinct keys must not drop each other's update."""
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_settings_merge({"inferenceParams": {}})
barrier = threading.Barrier(2)
def writer(key: str, value: float) -> None:
barrier.wait()
studio_db.upsert_chat_settings_merge({"inferenceParams": {key: value}})
t1 = threading.Thread(target = writer, args = ("temperature", 0.7))
t2 = threading.Thread(target = writer, args = ("topP", 0.9))
t1.start()
t2.start()
t1.join()
t2.join()
merged = studio_db.list_chat_settings()["inferenceParams"]
assert merged.get("temperature") == 0.7
assert merged.get("topP") == 0.9
def test_settings_merge_preserves_nested_keys(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_settings_merge(
{"inferenceParams": {"temperature": 0.5, "topP": 0.8}}
)
studio_db.upsert_chat_settings_merge({"inferenceParams": {"temperature": 0.9}})
params = studio_db.list_chat_settings()["inferenceParams"]
assert params == {"temperature": 0.9, "topP": 0.8}
def test_settings_merge_quarantines_corrupt_json_and_rejects_partial_patch(
tmp_path,
monkeypatch,
):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_settings_merge(
{"inferenceParams": {"temperature": 0.5, "topP": 0.8}}
)
conn = studio_db.get_connection()
try:
conn.execute(
"UPDATE chat_settings SET value_json = ? WHERE key = ?",
('{"temperature": 0.5', "inferenceParams"),
)
conn.commit()
finally:
conn.close()
with pytest.raises(studio_db.CorruptSettingsError):
studio_db.upsert_chat_settings_merge({"inferenceParams": {"temperature": 0.9}})
conn = studio_db.get_connection()
try:
quarantined = conn.execute(
"SELECT key, value_json, reason FROM chat_settings_quarantine"
).fetchall()
remaining = conn.execute(
"SELECT key FROM chat_settings WHERE key = ?",
("inferenceParams",),
).fetchall()
finally:
conn.close()
assert [row["key"] for row in quarantined] == ["inferenceParams"]
assert quarantined[0]["reason"] == "json_decode_error"
assert remaining == []
def test_settings_merge_replaces_corrupt_scalar_after_quarantine(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_settings_merge({"autoTitle": False})
conn = studio_db.get_connection()
try:
conn.execute(
"UPDATE chat_settings SET value_json = ? WHERE key = ?",
("not-json", "autoTitle"),
)
conn.commit()
finally:
conn.close()
settings = studio_db.upsert_chat_settings_merge({"autoTitle": True})
assert settings["autoTitle"] is True
conn = studio_db.get_connection()
try:
quarantined = conn.execute(
"SELECT key, reason FROM chat_settings_quarantine"
).fetchall()
finally:
conn.close()
assert [(row["key"], row["reason"]) for row in quarantined] == [
("autoTitle", "json_decode_error")
]
def test_list_chat_messages_for_threads_chunks_over_900_ids(tmp_path, monkeypatch):
"""SQLite host-parameter limit is 999 on older builds; chunk at 900."""
_reset_studio_db(tmp_path, monkeypatch)
n = 901
for i in range(n):
studio_db.upsert_chat_thread(
{
"id": f"t-{i}",
"title": "T",
"modelType": "base",
"modelId": "m",
"pairId": None,
"archived": False,
"createdAt": 1_700_000_000_000 + i,
}
)
studio_db.upsert_chat_message(
{
"id": f"m-{i}",
"threadId": f"t-{i}",
"parentId": None,
"role": "user",
"content": [{"type": "text", "text": "hi"}],
"createdAt": 1_700_000_000_000 + i,
}
)
out = studio_db.list_chat_messages_for_threads([f"t-{i}" for i in range(n)])
assert len(out) == n
assert {m["threadId"] for m in out} == {f"t-{i}" for i in range(n)}
# ---------------------------------------------------------------------------
# Legacy Dexie import ledger
# ---------------------------------------------------------------------------
def test_legacy_imports_empty_by_default(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
assert studio_db.list_chat_legacy_imports() == []
def test_legacy_imports_records_and_lists(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
accepted, inserted = studio_db.upsert_chat_legacy_imports(
["legacy-a", "legacy-b", "legacy-c"],
)
assert accepted == 3
assert inserted == 3
assert set(studio_db.list_chat_legacy_imports()) == {
"legacy-a",
"legacy-b",
"legacy-c",
}
def test_legacy_imports_is_idempotent(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
accepted1, inserted1 = studio_db.upsert_chat_legacy_imports(
["legacy-a", "legacy-b"],
)
accepted2, inserted2 = studio_db.upsert_chat_legacy_imports(
["legacy-b", "legacy-c"],
)
assert (accepted1, inserted1) == (2, 2)
# legacy-b is already in the ledger, only legacy-c is genuinely new.
assert (accepted2, inserted2) == (2, 1)
assert set(studio_db.list_chat_legacy_imports()) == {
"legacy-a",
"legacy-b",
"legacy-c",
}
def test_legacy_imports_dedups_input(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
accepted, inserted = studio_db.upsert_chat_legacy_imports(
["x", "x", "y", "x"],
)
# accepted is the deduped non-empty input size; inserted is the rows
# actually new in the ledger after ON CONFLICT DO NOTHING.
assert accepted == 2
assert inserted == 2
assert set(studio_db.list_chat_legacy_imports()) == {"x", "y"}
def test_legacy_imports_ignores_empty(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
assert studio_db.upsert_chat_legacy_imports([]) == (0, 0)
assert studio_db.upsert_chat_legacy_imports(["", None]) == (0, 0) # type: ignore[list-item]
assert studio_db.list_chat_legacy_imports() == []