unsloth/studio/backend/tests/test_profile_stats.py
Michael Han a7031d603f
Studio: profile usage stats (#7593)
* Studio: add profile usage stats and tidy the personalization panel

Adds a "Your stats" section to the Profile settings tab, built entirely
from local history in studio.db. No telemetry, nothing uploaded.

Backend
- storage/profile_stats_db.py folds every metric in one streaming pass
  over chat_messages, memoised against a (count, max created_at)
  fingerprint so reopening the tab is free until history changes.
- routes/profile_stats.py serves GET /api/profile/stats from a worker
  thread, so a cold pass cannot stall token streaming.

Frontend
- Headline tiles, a token activity grid with daily/weekly/cumulative
  modes, activity insights, most used models, hour and weekday rhythms,
  and training run totals.
- The stats panel is lazy loaded so recharts stays out of the main
  bundle.
- Personalization panel now puts a larger avatar beside the two name
  fields, with picture options moved into an edit popover.
- "Sloth in greeting" moves to Chat defaults, next to the other chat
  toggles.

Tests: 9 backend tests including a regression test that the endpoint
does not block the event loop, plus formatting unit tests.

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

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

* Studio: correct profile stat aggregation

Six accuracy fixes, each with a test that fails without it.

- firstTokenTime is already an elapsed duration, not a timestamp.
  Subtracting streamStartTime made the comparison false for every real
  message, so "Average time to first token" was always empty.
- Forking clones the whole ancestry into the new thread with the
  original timestamps. Those copies were counted again, doubling
  tokens, messages, attachments and activity for the source
  conversation. Rows older than the fork are now skipped.
- training_metrics.num_tokens is state.num_input_tokens_seen, a running
  total logged at each step, so summing the samples multiplied the real
  figure. Take each run's final counter, matching get_run_metrics.
- A resumed run continues its source's step and token counters from the
  checkpoint, so adding both reported the same progress twice. Only
  runs no later run resumed from are counted.
- Days, hours and weekdays were bucketed in the server's timezone while
  the client parses the keys as browser-local. The endpoint now takes
  the caller's getTimezoneOffset().
- A turn with no contextUsage.modelId fell back to the thread's
  model_id, which tracks the current selection and so misattributed
  older turns after a mid-conversation switch. Those turns are left
  uncredited instead.

* Studio: further profile stat corrections

Follow-up to the previous pass, each with a test that fails without it.

- Cancelling a run also sets resume_blocked, so filtering on that flag
  alone dropped the steps and tokens of cancelled work while still
  counting the run and its duration. Only a run a later run resumed
  from is excluded now: the resume claim leaves output_dir intact,
  cancelling clears it, which tells the two apart.
- A fixed getTimezoneOffset() was applied to every historical message,
  so winter records read an hour out when the panel is opened on summer
  time, moving messages near midnight onto the wrong day. The endpoint
  now takes an IANA timezone and converts each timestamp with its own
  offset, keeping the fixed offset as the fallback.
- A fork with no new turn was skipped before its thread was registered,
  so it did not appear in the chat total until its first message. The
  thread is now counted before the cloned rows are suppressed.
- Routed and aliased responses record the model that actually answered
  in responseDetails.responseModelId, while contextUsage.modelId stays
  the requested checkpoint. Most used models now prefers the former.
- Renamed runs showed the model label instead of the name the user
  chose. The name leads and the model moves beside the dataset.

* Studio: harden profile stats against deleted and malformed history

- Deleting a forked conversation's source left the fork holding the only
  copies of those messages, but the skip still dropped them because
  forked_from_thread_id is not a foreign key. The suppression now joins
  the source row, so copies are only ignored while the originals survive.
- delete_run never clears the predecessor's resume_blocked, so removing
  a continuation stranded its source at zero steps and tokens while the
  run stayed visible. Supersession now requires a continuation that
  still exists.
- created_at is a client-supplied integer stored unchecked, so one
  out-of-range row made datetime raise and returned 500 for the whole
  panel. Those rows are now skipped for day and hour buckets.
- A future-dated row satisfied the current-streak window, reporting days
  that have not happened. The last active day must now be today or
  yesterday.
- Compact numbers rounded 999,999 to "1000K". Rounding into the next
  unit now steps up the suffix.
- Escape or a programmatic close unmounts the Profile tab without a
  blur, dropping an in-progress name edit. Drafts are committed on
  unmount; both saves no-op when unchanged.
- Stats copy no longer claims the numbers never leave the device, which
  is untrue when Studio is reached from another machine. It states what
  actually holds: nothing is collected or sent to Unsloth.

* Studio: dedupe sibling forks and group comparison panes

- Deleting a thread that had been forked more than once left every
  sibling holding a copy of the shared ancestry, and the source-existence
  gate then let all of them count it, multiplying the usage by the number
  of forks. One fork per dead source is now elected to keep its copies,
  the one carrying the most, so nothing is lost or counted twice.
- Compare mode persists one thread per pane under a shared pair_id and
  the sidebar renders them as a single conversation. The chat total now
  keys on the pair, so one comparison is one chat and average tokens per
  chat is not halved.
- Corrected the note on cumulative mode: it is the running total across
  the displayed window, not lifetime. Seeding it with everything older
  than the window would flatten every bar against a baseline the grid
  has no room to show.

* Studio: count fork clones per message and drop future streaks

- A pre-fork message can be pruned from its original thread while the
  clone stays in the fork. The suppression only checked that the source
  thread existed, so that message's tokens and activity vanished. Clones
  are now matched to the row they came from, and one fork per source is
  elected to stand in whenever the original is gone, whether the whole
  thread or just that message went.
- Future-dated history was filtered out of the current streak but still
  padded the longest streak and could be reported as the last active
  day. It is dropped before any streak field is computed.
- Cumulative mode labelled its tooltip "week of" while showing the
  running total. It now reports that week's tokens; the bar height still
  uses the running total.
- The activity grid and weekday axis formatted dates with the browser
  language rather than the language chosen in Settings, and the memos
  could not react to a change. They follow the app locale now.

The fork fixtures were also corrected: fork_chat_thread copies created_at
verbatim, so a clone carries the original's timestamp, which the previous
fixtures did not model.

* Studio: drop the hour and weekday rhythm cards

Removes the "When you work" and "Your week" row from the Profile stats.

The backend stopped computing the hour and weekday buckets too, since
nothing else read them, and the payload no longer carries them. The
timezone and malformed-timestamp tests now assert on day buckets, which
is what the activity grid and streaks actually use; the daylight-saving
case moved to a timestamp where the hour of drift crosses midnight so
the day still proves it.

This was the only recharts consumer in the profile panel, so the lazy
split's note about keeping charts out of the main bundle no longer
applies and has been corrected. Build drops about 22 KB across two
fewer chunks.

* Studio: harden the profile stats aggregation

Four fixes from review:

- _as_float raised OverflowError on a JSON integer wider than float, so
  one oversized counter returned 500 for the whole panel. It degrades to
  zero now, like every other unreadable field.
- Fork dedup elected one winner per source thread, but fork_chat_thread
  copies a single parent_id branch, so sibling forks of a retry and a
  regeneration hold different rows. Electing per original message keeps
  each branch when the source is deleted.
- create_run claims a resume source before the continuation logs its
  first step, so a continuation that failed early took the source's
  completed steps and tokens with it. Supersession now needs the
  continuation to have reached the source's step.
- Cumulative activity is a running total over the displayed window, but
  a narrow card trims older weeks without rebasing, opening the first
  visible bar at the hidden total and flattening the rest.

* Studio: record resume lineage and tighten the stats fallbacks

Four more from review:

- peakDay and activeDays read every day bucket, so a future-dated
  message could headline a date the activity grid cannot show. Both now
  stop at today, like the streaks and the grid already did.
- Supersession matched runs by output_dir, but cancelling a resumed
  continuation nulls that column, which un-superseded the source and
  double-counted their shared steps and tokens. training_runs now stores
  resumed_from_run_id, which create_run already accepted and discarded.
  Rows written before the column stay on the output_dir heuristic.
- Token totals read contextUsage only, while the response details sheet
  already falls back to serverTimings prompt_n, predicted_n and cache_n.
  Local-engine replies were undercounted against what the app displays.
- The stats section was missing from the settings search index, so the
  headline labels were unreachable from search.

* Studio: close profile stats review gaps

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-07-29 08:02:14 -07:00

1236 lines
47 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
"""Profile statistics aggregation over local chat/training history."""
import json
import time
from datetime import datetime, timedelta, timezone
import pytest
from storage import profile_stats_db, studio_db
from storage.profile_stats_db import compute_profile_stats, invalidate_profile_stats_cache
@pytest.fixture
def stats_db(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setenv("UNSLOTH_STUDIO_PROJECTS_HOME", str(tmp_path / "Projects"))
monkeypatch.setattr(studio_db, "_schema_ready", False)
invalidate_profile_stats_cache()
yield
invalidate_profile_stats_cache()
# _seed_thread writes the assistant reply this far after the user turn, and
# fork_chat_thread copies created_at verbatim, so clones must reuse it.
REPLY_DELAY = timedelta(seconds = 10)
def _ms(when: datetime) -> int:
return int(when.timestamp() * 1000)
def _seed_thread(conn, thread_id: str, model_id: str, turns: list[tuple[datetime, dict]]):
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES (?, ?, 'base', ?, ?, ?)",
(thread_id, f"Thread {thread_id}", model_id, _ms(turns[0][0]), _ms(turns[-1][0])),
)
for index, (when, metadata) in enumerate(turns):
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
f"{thread_id}-u{index}",
thread_id,
"user",
json.dumps([{"type": "text", "text": "hi"}]),
None,
_ms(when),
),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
f"{thread_id}-a{index}",
thread_id,
"assistant",
json.dumps([{"type": "text", "text": "hello"}]),
json.dumps(metadata),
_ms(when + REPLY_DELAY),
),
)
def _metadata(
prompt: int,
completion: int,
*,
speed: float = 40.0,
tools: int = 0,
) -> dict:
return {
"contextUsage": {
"promptTokens": prompt,
"completionTokens": completion,
"totalTokens": prompt + completion,
"cachedTokens": 5,
"modelId": "unsloth/gpt-oss-20b",
},
"timing": {
# The adapter writes streamStartTime as an epoch stamp and
# firstTokenTime as the elapsed ms before the first chunk.
"streamStartTime": 1_760_000_000_000,
"firstTokenTime": 200,
"totalStreamTime": 2000,
"tokenCount": completion,
"tokensPerSecond": speed,
"toolCallCount": tools,
},
}
def test_empty_history_returns_zeroed_payload(stats_db):
stats = compute_profile_stats(days = 30)
assert stats["totals"]["messages"] == 0
assert stats["totals"]["totalTokens"] == 0
assert stats["streak"] == {"current": 0, "longest": 0, "lastActiveDay": None}
assert stats["peakDay"] is None
assert stats["longestChat"] is None
assert len(stats["daily"]) == 30
assert all(day["tokens"] == 0 for day in stats["daily"])
def test_tokens_streaks_and_models_are_aggregated(stats_db):
today = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(
conn,
"t1",
"unsloth/gpt-oss-20b",
[
(today - timedelta(days = 2), _metadata(100, 50, speed = 30.0, tools = 2)),
(today - timedelta(days = 1), _metadata(200, 80, speed = 55.0)),
(today, _metadata(300, 120, speed = 120.0, tools = 1)),
],
)
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 30)
totals = stats["totals"]
assert totals["threads"] == 1
assert totals["messages"] == 6
assert totals["userMessages"] == 3
assert totals["assistantMessages"] == 3
assert totals["promptTokens"] == 600
assert totals["completionTokens"] == 250
assert totals["totalTokens"] == 850
assert totals["cachedTokens"] == 15
assert totals["toolCalls"] == 3
assert totals["activeDays"] == 3
assert stats["streak"] == {
"current": 3,
"longest": 3,
"lastActiveDay": today.date().isoformat(),
}
assert stats["peakDay"] == {"date": today.date().isoformat(), "tokens": 420}
assert stats["models"][0]["id"] == "unsloth/gpt-oss-20b"
assert stats["models"][0]["label"] == "gpt-oss-20b"
assert stats["models"][0]["messages"] == 3
assert stats["speed"]["bestTokensPerSecond"] == 120.0
assert stats["speed"]["averageTokensPerSecond"] == pytest.approx(68.333, rel = 1e-3)
# Each turn is a user message plus an assistant reply 10s later.
assert stats["longestChat"]["seconds"] == 30
assert stats["longestChat"]["messages"] == 6
def test_completion_tokens_fall_back_to_adapter_count(stats_db):
"""Local engines can omit the usage chunk; timing.tokenCount stands in."""
now = datetime.now().replace(hour = 9, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(
conn,
"t2",
"local-gguf",
[(now, {"timing": {"tokenCount": 64, "tokensPerSecond": 12.0}})],
)
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7)
assert stats["totals"]["completionTokens"] == 64
assert stats["totals"]["totalTokens"] == 64
# No modelId on the turn, so it is not credited to any model. The thread's
# model_id follows the current selection and would misattribute after a
# mid-conversation switch.
assert stats["models"] == []
def test_session_time_ignores_long_idle_gaps(stats_db):
"""A thread reopened days later must not count the idle time as chatting."""
start = datetime.now().replace(hour = 10, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(
conn,
"t3",
"m",
[(start - timedelta(days = 3), _metadata(10, 10)), (start, _metadata(10, 10))],
)
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 30)
# Two turns of 10s each; the 3-day gap between them is excluded.
assert stats["longestChat"]["seconds"] == 20
assert stats["totals"]["chatSeconds"] == 20
def test_broken_metadata_does_not_break_aggregation(stats_db):
now = datetime.now()
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES ('t4', 'Broken', 'base', 'm', ?, ?)",
(_ms(now), _ms(now)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, created_at) "
"VALUES ('t4-a0', 't4', 'assistant', '[]', ?, ?)",
("{not json", _ms(now)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, created_at) "
"VALUES ('t4-a1', 't4', 'assistant', '[]', ?, ?)",
(json.dumps({"contextUsage": {"totalTokens": "lots"}}), _ms(now)),
)
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7)
assert stats["totals"]["assistantMessages"] == 2
assert stats["totals"]["totalTokens"] == 0
def test_training_totals(stats_db):
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, ended_at, total_steps, final_step, final_loss, duration_seconds) "
"VALUES ('r1', 'completed', 'unsloth/llama-3-8b', 'tatsu-lab/alpaca', '{}', "
"'2026-01-01T10:00:00', '2026-01-01T11:00:00', 100, 100, 0.42, 3600)",
)
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, final_loss, duration_seconds) "
"VALUES ('r2', 'error', 'unsloth/qwen3-4b', 'my/dataset', '{}', "
"'2026-01-02T10:00:00', 100, 20, 1.8, 600)",
)
# num_tokens is a running total, so the last row is the run's figure.
conn.executemany(
"INSERT INTO training_metrics (run_id, step, loss, num_tokens) VALUES (?, ?, ?, ?)",
[("r1", step, 1.0, (step + 1) * 1000) for step in range(10)],
)
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7)
training = stats["training"]
assert training["runs"] == 2
assert training["completed"] == 1
assert training["steps"] == 120
assert training["tokens"] == 10_000
assert training["seconds"] == 4200
assert training["models"] == 2
assert training["bestLoss"] == pytest.approx(0.42)
assert training["recent"][0]["id"] == "r2"
assert training["recent"][0]["modelLabel"] == "qwen3-4b"
def test_first_token_time_is_read_as_a_duration(stats_db):
"""firstTokenTime is `Date.now() - streamStartTime`, not a wall-clock stamp.
Treating it as a stamp and subtracting streamStartTime made the comparison
fail for every real message, so the average was always empty.
"""
now = datetime.now()
conn = studio_db.get_connection()
try:
_seed_thread(conn, "tft", "m", [(now, _metadata(10, 10))])
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7)
assert stats["speed"]["averageFirstTokenMs"] == pytest.approx(200.0)
def test_forked_threads_do_not_double_count_copied_history(stats_db):
"""Forking clones the ancestry, so the copies must not be counted again."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "src", "m", [(now - timedelta(hours = 2), _metadata(100, 50))])
conn.commit()
finally:
conn.close()
before = compute_profile_stats(days = 7)
assert before["totals"]["totalTokens"] == 150
assert before["totals"]["messages"] == 2
fork_at = now
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, "
"updated_at, forked_from_thread_id, forked_from_message_id) "
"VALUES ('fork', 'fork of src', 'base', 'm', ?, ?, 'src', 'src-a0')",
(_ms(fork_at), _ms(fork_at)),
)
# The clone keeps the original timestamp, exactly as fork_chat_thread does.
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('fork-a0', 'fork', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)),
)
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
after = compute_profile_stats(days = 7)
assert after["totals"]["totalTokens"] == 150
assert after["totals"]["messages"] == 2
# A genuinely new turn in the fork still counts.
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('fork-a1', 'fork', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(10, 5)), _ms(fork_at + timedelta(minutes = 1))),
)
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
grown = compute_profile_stats(days = 7)
assert grown["totals"]["totalTokens"] == 165
assert grown["totals"]["messages"] == 3
def test_resumed_runs_do_not_double_count_steps_or_tokens(stats_db):
"""A resume continues the source's counters, so only the tail is counted."""
conn = studio_db.get_connection()
try:
# 'stopped' at step 10, then claimed by the resume below. The claim sets
# resume_blocked and leaves output_dir, which is how it is told apart
# from a cancelled run.
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) "
"VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, 600, "
"'/runs/out', 1)",
)
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) "
"VALUES ('cont', 'completed', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, 15, 300, "
"'/runs/out', 0)",
)
conn.executemany(
"INSERT INTO training_metrics (run_id, step, num_tokens) VALUES (?, ?, ?)",
# The continuation's counter picks up where the source stopped.
[("src", step, step * 100) for step in range(1, 11)]
+ [("cont", step, step * 100) for step in range(11, 16)],
)
conn.commit()
finally:
conn.close()
training = compute_profile_stats(days = 7)["training"]
# Training reached step 15, not 10 + 15.
assert training["steps"] == 15
assert training["tokens"] == 1500
# Both attempts still show up as runs.
assert training["runs"] == 2
def test_cancelled_runs_keep_the_work_they_did(stats_db):
"""Cancelling sets resume_blocked too, but nothing resumed from that run."""
conn = studio_db.get_connection()
try:
# mark_run_cancel_requested clears output_dir and sets resume_blocked.
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) "
"VALUES ('cancelled', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 8, "
"400, NULL, 1)",
)
conn.executemany(
"INSERT INTO training_metrics (run_id, step, num_tokens) VALUES (?, ?, ?)",
[("cancelled", step, step * 100) for step in range(1, 9)],
)
conn.commit()
finally:
conn.close()
training = compute_profile_stats(days = 7)["training"]
assert training["runs"] == 1
assert training["steps"] == 8
assert training["tokens"] == 800
def test_forks_count_as_chats_before_their_first_new_turn(stats_db):
"""A fork is a visible thread the moment it exists."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "orig", "m", [(now - timedelta(hours = 2), _metadata(100, 50))])
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, "
"updated_at, forked_from_thread_id, forked_from_message_id) "
"VALUES ('branch', 'fork', 'base', 'm', ?, ?, 'orig', 'orig-a0')",
(_ms(now), _ms(now)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('branch-a0', 'branch', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)),
)
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7)
# Two conversations, but the cloned turn is not counted twice.
assert stats["totals"]["threads"] == 2
assert stats["totals"]["messages"] == 2
assert stats["totals"]["totalTokens"] == 150
def test_tokens_follow_the_model_that_answered(stats_db):
"""A routed provider records the real producer in responseDetails."""
now = datetime.now()
metadata = _metadata(100, 50)
metadata["contextUsage"]["modelId"] = "openrouter/auto"
metadata["responseDetails"] = {"responseModelId": "anthropic/claude-sonnet-4"}
conn = studio_db.get_connection()
try:
_seed_thread(conn, "routed", "openrouter/auto", [(now, metadata)])
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7)
assert stats["models"][0]["id"] == "anthropic/claude-sonnet-4"
assert stats["models"][0]["tokens"] == 150
def test_recent_run_name_prefers_the_users_rename(stats_db):
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, display_name) VALUES ('named', 'completed', 'unsloth/llama-3-8b', "
"'tatsu-lab/alpaca', '{}', '2026-01-02T10:00:00', 'Support triage v3')",
)
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at) VALUES ('plain', 'completed', 'unsloth/qwen3-4b', 'my/dataset', '{}', "
"'2026-01-01T10:00:00')",
)
conn.commit()
finally:
conn.close()
recent = {run["id"]: run for run in compute_profile_stats(days = 7)["training"]["recent"]}
assert recent["named"]["name"] == "Support triage v3"
assert recent["named"]["modelLabel"] == "llama-3-8b"
# Unnamed runs fall back to the short label, not the full repo id.
assert recent["plain"]["name"] == "qwen3-4b"
def test_historical_daylight_saving_offsets_are_respected(stats_db):
"""A fixed offset would put a winter message on the wrong day.
2026-01-15 04:30 UTC is 23:30 on the 14th in New York, which is UTC-5 in
January. Reusing a summer offset of UTC-4 pushes it to 00:30 on the 15th,
so the one hour of drift crosses midnight and moves the activity grid.
"""
winter = datetime(2026, 1, 15, 4, 30, tzinfo = timezone.utc)
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES ('dst', 'dst', 'base', 'm', ?, ?)",
(int(winter.timestamp() * 1000), int(winter.timestamp() * 1000)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('dst-a0', 'dst', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(10, 10)), int(winter.timestamp() * 1000)),
)
conn.commit()
finally:
conn.close()
# A browser on summer time sends offset 240 (UTC-4) with the zone name.
named = compute_profile_stats(days = 366, tz_offset_minutes = 240, tz_name = "America/New_York")
invalidate_profile_stats_cache()
offset_only = compute_profile_stats(days = 366, tz_offset_minutes = 240)
assert {day["date"] for day in named["daily"] if day["messages"]} == {"2026-01-14"}
# The fixed offset lands an hour late, which is what the zone name fixes.
assert {day["date"] for day in offset_only["daily"] if day["messages"]} == {"2026-01-15"}
def test_unknown_timezone_falls_back_to_the_offset(stats_db):
now = datetime.now()
conn = studio_db.get_connection()
try:
_seed_thread(conn, "tzbad", "m", [(now, _metadata(10, 10))])
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7, tz_offset_minutes = 0, tz_name = "Not/A/Zone")
assert stats["totals"]["totalTokens"] == 20
def test_deleting_the_source_thread_keeps_the_forks_copies(stats_db):
"""forked_from_thread_id is not a foreign key, so it outlives the source."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "gone", "m", [(now - timedelta(hours = 2), _metadata(100, 50))])
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, "
"updated_at, forked_from_thread_id, forked_from_message_id) "
"VALUES ('kept', 'fork', 'base', 'm', ?, ?, 'gone', 'gone-a0')",
(_ms(now), _ms(now)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('kept-a0', 'kept', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)),
)
conn.commit()
finally:
conn.close()
assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150
conn = studio_db.get_connection()
try:
conn.execute("DELETE FROM chat_threads WHERE id = 'gone'")
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
stats = compute_profile_stats(days = 7)
# The fork now holds the only copy, so it must still be counted once.
assert stats["totals"]["totalTokens"] == 150
assert stats["totals"]["messages"] == 1
def test_sibling_forks_do_not_multiply_a_deleted_source(stats_db):
"""Two forks of one thread must not both re-count the shared ancestry."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
older = now - timedelta(hours = 3)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "root", "m", [(older, _metadata(100, 50))])
for fork_id in ("forkA", "forkB"):
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, "
"updated_at, forked_from_thread_id, forked_from_message_id) "
"VALUES (?, 'fork', 'base', 'm', ?, ?, 'root', 'root-a0')",
(fork_id, _ms(now), _ms(now)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, "
"metadata_json, created_at) VALUES (?, ?, 'assistant', '[]', ?, ?)",
(
f"{fork_id}-a0",
fork_id,
json.dumps(_metadata(100, 50)),
_ms(older + REPLY_DELAY),
),
)
conn.commit()
finally:
conn.close()
assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150
conn = studio_db.get_connection()
try:
conn.execute("DELETE FROM chat_threads WHERE id = 'root'")
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
stats = compute_profile_stats(days = 7)
# Exactly one surviving copy is counted, not one per sibling fork.
assert stats["totals"]["totalTokens"] == 150
assert stats["totals"]["messages"] == 1
def test_deleting_an_original_message_keeps_the_forks_clone(stats_db):
"""Pruning one pre-fork message leaves the clone as the only copy."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
older = now - timedelta(hours = 3)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "orig", "m", [(older, _metadata(100, 50))])
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, "
"updated_at, forked_from_thread_id, forked_from_message_id) "
"VALUES ('branch', 'fork', 'base', 'm', ?, ?, 'orig', 'orig-a0')",
(_ms(now), _ms(now)),
)
# The clone keeps the original's timestamp and role.
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('branch-a0', 'branch', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(older + timedelta(seconds = 10))),
)
conn.commit()
finally:
conn.close()
# While the original is there the clone is ignored.
assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150
conn = studio_db.get_connection()
try:
conn.execute("DELETE FROM chat_messages WHERE id = 'orig-a0'")
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
stats = compute_profile_stats(days = 7)
# The thread survives but that message does not, so the clone stands in.
assert stats["totals"]["totalTokens"] == 150
assert stats["totals"]["assistantMessages"] == 1
def test_future_history_cannot_pad_the_longest_streak(stats_db):
"""Only the current streak was guarded; longest and lastActiveDay were not."""
base = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(
conn,
"skew",
"m",
[(base + timedelta(days = day), _metadata(10, 10)) for day in (3, 4, 5, 6)],
)
conn.commit()
finally:
conn.close()
streak = compute_profile_stats(days = 366)["streak"]
assert streak == {"current": 0, "longest": 0, "lastActiveDay": None}
def test_comparison_panes_count_as_one_chat(stats_db):
"""Compare mode stores a thread per pane; the sidebar shows one chat."""
now = datetime.now()
conn = studio_db.get_connection()
try:
for pane in ("left", "right"):
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, pair_id, "
"created_at, updated_at) VALUES (?, 'compare', 'base', 'm', 'pair-1', ?, ?)",
(pane, _ms(now), _ms(now)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, "
"metadata_json, created_at) VALUES (?, ?, 'assistant', '[]', ?, ?)",
(f"{pane}-a0", pane, json.dumps(_metadata(100, 50)), _ms(now)),
)
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7)
assert stats["totals"]["threads"] == 1
# Both panes still contribute their own messages and tokens.
assert stats["totals"]["messages"] == 2
assert stats["totals"]["totalTokens"] == 300
def test_deleting_a_continuation_restores_its_source(stats_db):
"""delete_run leaves resume_blocked set, so supersession needs a live tail."""
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, output_dir, resume_blocked) "
"VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, "
"'/runs/out', 1)",
)
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, output_dir, resume_blocked) "
"VALUES ('cont', 'completed', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, 15, "
"'/runs/out', 0)",
)
conn.commit()
finally:
conn.close()
assert compute_profile_stats(days = 7)["training"]["steps"] == 15
conn = studio_db.get_connection()
try:
conn.execute("DELETE FROM training_runs WHERE id = 'cont'")
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
# With the continuation gone the source is the only record of that work.
assert compute_profile_stats(days = 7)["training"]["steps"] == 10
def test_out_of_range_timestamps_do_not_break_the_panel(stats_db):
"""created_at is client supplied and SQLite stores it unchecked."""
now = datetime.now()
conn = studio_db.get_connection()
try:
_seed_thread(conn, "sane", "m", [(now, _metadata(10, 10))])
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES ('bad', 'bad', 'base', 'm', 1, 1)",
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('bad-a0', 'bad', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(7, 3)), 99_999_999_999_999_999),
)
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 7)
# Totals still include the bad row; only its day bucket is dropped, so the
# activity grid holds just the one well-formed day.
assert stats["totals"]["totalTokens"] == 30
assert stats["totals"]["messages"] == 3
assert stats["totals"]["activeDays"] == 1
assert sum(day["messages"] for day in stats["daily"]) == 2
def test_future_dated_history_is_not_a_current_streak(stats_db):
"""A client clock that ran ahead must not report a streak that has not happened."""
future = datetime.now() + timedelta(days = 5)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "ahead", "m", [(future, _metadata(10, 10))])
conn.commit()
finally:
conn.close()
streak = compute_profile_stats(days = 366)["streak"]
assert streak["current"] == 0
def test_days_and_hours_use_the_callers_timezone(stats_db):
"""A remote browser must not be bucketed against the server's calendar."""
# 01:30 UTC. In UTC that is one day; at UTC-4 it is 21:30 the day before.
when = datetime(2026, 3, 10, 1, 30, tzinfo = timezone.utc)
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES ('tz', 'tz', 'base', 'm', ?, ?)",
(int(when.timestamp() * 1000), int(when.timestamp() * 1000)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('tz-a0', 'tz', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(10, 10)), int(when.timestamp() * 1000)),
)
conn.commit()
finally:
conn.close()
at_utc = compute_profile_stats(days = 366, tz_offset_minutes = 0)
invalidate_profile_stats_cache()
at_minus_four = compute_profile_stats(days = 366, tz_offset_minutes = 240)
utc_days = {day["date"] for day in at_utc["daily"] if day["messages"]}
local_days = {day["date"] for day in at_minus_four["daily"] if day["messages"]}
assert utc_days == {"2026-03-10"}
assert local_days == {"2026-03-09"}
def test_repeat_calls_are_served_from_cache_until_history_changes(stats_db):
now = datetime.now()
conn = studio_db.get_connection()
try:
_seed_thread(conn, "t5", "m", [(now, _metadata(10, 10))])
conn.commit()
finally:
conn.close()
first = compute_profile_stats(days = 7)
second = compute_profile_stats(days = 7)
assert first is second
conn = studio_db.get_connection()
try:
_seed_thread(conn, "t6", "m", [(now, _metadata(20, 20))])
conn.commit()
finally:
conn.close()
third = compute_profile_stats(days = 7)
assert third is not first
assert third["totals"]["totalTokens"] == 60
def test_daily_series_is_dense_and_clamped(stats_db):
stats = compute_profile_stats(days = 10_000)
assert len(stats["daily"]) == profile_stats_db.MAX_DAILY_DAYS
dates = [day["date"] for day in stats["daily"]]
assert dates == sorted(dates)
assert len(set(dates)) == len(dates)
def test_route_does_not_block_the_event_loop(stats_db, monkeypatch):
"""A cold stats pass must not stall streaming for the rest of the app.
The aggregation is CPU-bound and can run for a second on large histories,
so the route offloads it to a worker thread. This drives the endpoint with a
heartbeat coroutine alongside it and asserts the loop kept ticking.
"""
import asyncio
from routes import profile_stats as route_module
def slow_compute(
days = 366,
tz_offset_minutes = 0,
tz_name = "",
):
time.sleep(0.5)
return {"totals": {"messages": 0}}
monkeypatch.setattr(route_module, "compute_profile_stats", slow_compute)
async def drive() -> int:
ticks = 0
async def heartbeat() -> None:
nonlocal ticks
while True:
await asyncio.sleep(0.01)
ticks += 1
beat = asyncio.create_task(heartbeat())
try:
await route_module.get_profile_stats(
days = 366, tz_offset_minutes = 0, current_subject = "unsloth"
)
finally:
beat.cancel()
return ticks
ticks = asyncio.run(drive())
# ~50 ticks fit in 0.5s; a blocking call on the loop would yield 0.
assert ticks > 10, f"event loop stalled during stats computation ({ticks} ticks)"
def test_an_oversized_token_counter_does_not_break_the_panel(stats_db):
"""json parses ints of any width; float() gives up long before that."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "sane", "m", [(now - timedelta(hours = 1), _metadata(100, 50))])
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES ('bad', 'Bad', 'base', 'm', ?, ?)",
(_ms(now), _ms(now)),
)
huge = _metadata(100, 50)
# Wider than float can hold, so every counter reads as unusable.
oversized = int("9" * 309)
for field in ("promptTokens", "completionTokens", "totalTokens"):
huge["contextUsage"][field] = oversized
huge["timing"]["tokenCount"] = oversized
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('bad-a0', 'bad', 'assistant', '[]', ?, ?)",
(json.dumps(huge), _ms(now)),
)
conn.commit()
finally:
conn.close()
# The row degrades to zero instead of raising, and the absurd counter
# never reaches the totals; the healthy thread still reports.
stats = compute_profile_stats(days = 7)
assert stats["totals"]["totalTokens"] == 150
assert stats["totals"]["messages"] == 3
def test_divergent_sibling_forks_keep_their_own_branch_messages(stats_db):
"""fork_chat_thread copies one parent_id branch, so siblings differ."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
older = now - timedelta(hours = 3)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "root", "m", [(older, _metadata(100, 50))])
# A regeneration of the same turn, a second later.
regenerated = older + REPLY_DELAY + timedelta(seconds = 1)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('root-a1', 'root', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(200, 100)), _ms(regenerated)),
)
# One fork per branch: each carries only its own reply.
for fork_id, stamp, meta in (
("forkA", older + REPLY_DELAY, _metadata(100, 50)),
("forkB", regenerated, _metadata(200, 100)),
):
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, "
"updated_at, forked_from_thread_id, forked_from_message_id) "
"VALUES (?, 'fork', 'base', 'm', ?, ?, 'root', 'root-a0')",
(fork_id, _ms(now), _ms(now)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, "
"metadata_json, created_at) VALUES (?, ?, 'assistant', '[]', ?, ?)",
(f"{fork_id}-a0", fork_id, json.dumps(meta), _ms(stamp)),
)
conn.commit()
finally:
conn.close()
# Both originals counted, both clones suppressed.
assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 450
conn = studio_db.get_connection()
try:
conn.execute("DELETE FROM chat_threads WHERE id = 'root'")
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
stats = compute_profile_stats(days = 7)
# Each branch survives in exactly one fork, so nothing is lost or doubled.
assert stats["totals"]["totalTokens"] == 450
assert stats["totals"]["messages"] == 2
def test_a_resume_that_never_logged_a_step_keeps_the_source_counters(stats_db):
"""create_run claims the source before the continuation flushes a metric."""
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) "
"VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, 600, "
"'/runs/out', 1)",
)
# Errored before its first training step: no final_step, no metrics.
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) "
"VALUES ('cont', 'error', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, NULL, 5, "
"'/runs/out', 0)",
)
conn.executemany(
"INSERT INTO training_metrics (run_id, step, num_tokens) VALUES (?, ?, ?)",
[("src", step, step * 100) for step in range(1, 11)],
)
conn.commit()
finally:
conn.close()
training = compute_profile_stats(days = 7)["training"]
# The source's completed work is still the only work there is.
assert training["steps"] == 10
assert training["tokens"] == 1000
def test_a_future_dated_message_cannot_become_the_peak_day(stats_db):
"""The grid stops at today, so the headlines have to as well."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "past", "m", [(now - timedelta(days = 1), _metadata(100, 50))])
# A skewed client clock landed this one a week out.
_seed_thread(conn, "ahead", "m", [(now + timedelta(days = 7), _metadata(900, 900))])
conn.commit()
finally:
conn.close()
stats = compute_profile_stats(days = 30)
assert stats["peakDay"] is not None
assert stats["peakDay"]["date"] == (now - timedelta(days = 1)).date().isoformat()
# The future day is not an active day either.
assert stats["totals"]["activeDays"] == 1
def test_cancelling_a_resumed_run_keeps_the_source_superseded(stats_db):
"""mark_run_cancel_requested nulls output_dir, so lineage carries it."""
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked) "
"VALUES ('src', 'stopped', 'm', 'd', '{}', '2026-01-01T10:00:00', 20, 10, 600, "
"'/runs/out', 1)",
)
# Resumed, then cancelled: output_dir cleared, counters still cumulative.
conn.execute(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, total_steps, final_step, duration_seconds, output_dir, resume_blocked, "
"resumed_from_run_id) "
"VALUES ('cont', 'stopped', 'm', 'd', '{}', '2026-01-02T10:00:00', 20, 15, 300, "
"NULL, 1, 'src')",
)
conn.executemany(
"INSERT INTO training_metrics (run_id, step, num_tokens) VALUES (?, ?, ?)",
[("src", step, step * 100) for step in range(1, 11)]
+ [("cont", step, step * 100) for step in range(11, 16)],
)
conn.commit()
finally:
conn.close()
training = compute_profile_stats(days = 7)["training"]
# 15, not 10 + 15: the cancelled continuation still carries the shared work.
assert training["steps"] == 15
assert training["tokens"] == 1500
def test_server_timings_stand_in_for_a_missing_usage_chunk(stats_db):
"""llama.cpp reports its own counters; the details sheet already uses them."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES ('local', 'Local', 'base', 'm', ?, ?)",
(_ms(now), _ms(now)),
)
metadata = _metadata(0, 0)
del metadata["contextUsage"]
metadata["timing"]["tokenCount"] = 0
metadata["serverTimings"] = {"prompt_n": 400, "predicted_n": 120, "cache_n": 90}
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('local-a0', 'local', 'assistant', '[]', ?, ?)",
(json.dumps(metadata), _ms(now)),
)
conn.commit()
finally:
conn.close()
totals = compute_profile_stats(days = 7)["totals"]
assert totals["promptTokens"] == 400
assert totals["completionTokens"] == 120
assert totals["totalTokens"] == 520
assert totals["cachedTokens"] == 90
def test_nested_fork_keeps_one_copy_after_the_middle_thread_is_deleted(stats_db):
"""Deleting a fork reconnects its descendants to the retained ancestor."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
root_time = now - timedelta(hours = 4)
middle_time = now - timedelta(hours = 2)
middle_new_time = now - timedelta(hours = 1)
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES ('root', 'root', 'base', 'm', ?, ?)",
(_ms(root_time), _ms(root_time)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('root-a0', 'root', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(root_time)),
)
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at, "
"forked_from_thread_id, forked_from_message_id) "
"VALUES ('middle', 'middle', 'base', 'm', ?, ?, 'root', 'root-a0')",
(_ms(middle_time), _ms(middle_new_time)),
)
conn.executemany(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES (?, 'middle', 'assistant', '[]', ?, ?)",
[
("middle-a0", json.dumps(_metadata(100, 50)), _ms(root_time)),
("middle-a1", json.dumps(_metadata(20, 10)), _ms(middle_new_time)),
],
)
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at, "
"forked_from_thread_id, forked_from_message_id) "
"VALUES ('leaf', 'leaf', 'base', 'm', ?, ?, 'middle', 'middle-a1')",
(_ms(now), _ms(now)),
)
conn.executemany(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES (?, 'leaf', 'assistant', '[]', ?, ?)",
[
("leaf-a0", json.dumps(_metadata(100, 50)), _ms(root_time)),
("leaf-a1", json.dumps(_metadata(20, 10)), _ms(middle_new_time)),
],
)
conn.commit()
finally:
conn.close()
before = compute_profile_stats(days = 7)["totals"]
assert before["messages"] == 2
assert before["totalTokens"] == 180
studio_db.delete_chat_threads(["middle"])
invalidate_profile_stats_cache()
conn = studio_db.get_connection()
try:
leaf = conn.execute(
"SELECT forked_from_thread_id FROM chat_threads WHERE id = 'leaf'"
).fetchone()
finally:
conn.close()
assert leaf["forked_from_thread_id"] == "root"
after = compute_profile_stats(days = 7)["totals"]
assert after["messages"] == 2
assert after["totalTokens"] == 180
def test_nested_resume_keeps_cumulative_totals_after_middle_run_deletion(stats_db):
"""Deleting a resumed run reconnects its continuation to the source."""
conn = studio_db.get_connection()
try:
conn.executemany(
"INSERT INTO training_runs (id, status, model_name, dataset_name, config_json, "
"started_at, final_step, output_dir, resume_blocked, resumed_from_run_id) "
"VALUES (?, ?, 'm', 'd', '{}', ?, ?, ?, ?, ?)",
[
("source", "stopped", "2026-01-01T10:00:00", 10, "/runs/out", 1, None),
("middle", "stopped", "2026-01-02T10:00:00", 15, "/runs/out", 1, "source"),
("tail", "completed", "2026-01-03T10:00:00", 20, "/runs/out", 0, "middle"),
],
)
conn.executemany(
"INSERT INTO training_metrics (run_id, step, num_tokens) VALUES (?, ?, ?)",
[("source", 10, 1000), ("middle", 15, 1500), ("tail", 20, 2000)],
)
conn.commit()
finally:
conn.close()
before = compute_profile_stats(days = 7)["training"]
assert before["steps"] == 20
assert before["tokens"] == 2000
studio_db.delete_run("middle")
invalidate_profile_stats_cache()
conn = studio_db.get_connection()
try:
tail = conn.execute(
"SELECT resumed_from_run_id FROM training_runs WHERE id = 'tail'"
).fetchone()
finally:
conn.close()
assert tail["resumed_from_run_id"] == "source"
after = compute_profile_stats(days = 7)["training"]
assert after["steps"] == 20
assert after["tokens"] == 2000
def test_inline_media_counts_as_attachments_without_double_counting(stats_db):
"""Compare messages keep uploads in content_json instead of attachments_json."""
now = datetime.now()
image = {"type": "image", "image": "data:image/png;base64,AAAA"}
audio = {"type": "audio", "audio": "data:audio/wav;base64,BBBB"}
conn = studio_db.get_connection()
try:
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, updated_at) "
"VALUES ('media', 'media', 'base', 'm', ?, ?)",
(_ms(now), _ms(now)),
)
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, attachments_json, "
"created_at) VALUES ('media-u0', 'media', 'user', ?, ?, ?)",
(
json.dumps([image, audio, image]),
json.dumps([{"id": "image-upload", "content": [image]}]),
_ms(now),
),
)
conn.commit()
finally:
conn.close()
totals = compute_profile_stats(days = 7)["totals"]
# The image appears in both storage representations and twice inline.
assert totals["attachments"] == 2