unsloth/tests/studio/test_chat_preset_builtin_invariants.py
Lee Jackson b65a7450ca
Studio: Dark theme refactor, right sidebar redesign, and chat UI polish (#5150)
* Dark theme refactor, right sidebar redesign, and chat UI polish

- Dark theme refactor
- Redesign right sidebar
- Further left sidebar adjustments
- Wider chat and content area; layout tweaks for chat content
- Rounded corners across elements for consistency
- Show chat message menu icons on menu-area hover, not only on message hover
- Assistant message menu icons now always visible; user messages keep on-hover
- Redesigned copy icon used consistently across chat blocks and messages
- Redesigned trash icon, applied consistently
- Unified icon sizing and style with the sidebar
- Adjusted icon colors across chat
- Fix on-hover background design for chat icons
- Fix tooltip from 'more' button staying visible after clicking elsewhere
- Adjust position and design of generation speed info text below messages
- Adjust design of token speed info popup
- Adjust sidebar scrollbar to cover recent chats only

* Recents sidebar rename, UI/theme refactor, layout and chat polish

UI & Theme:
- Dark theme refactor
- Consistent rounded corners across elements
- CSS polish and cleanup
- Remove unused logo image assets

Recents sidebar:
- Add 'more' button for options menu
- Support renaming conversations and training runs
- Confirmation dialog before deleting chats
- Add optional display_name column to training_runs (idempotent ALTER TABLE) so renaming doesn't lose model_name/dataset_name from the run config
- New PATCH /api/train/runs/{run_id} endpoint accepts { display_name: string | null }; empty/whitespace clears the override
- Sidebar shows display_name ?? model_name and exposes Rename in the row's More menu, mirroring the chat rename flow
- Cache last list response in localStorage and hydrate from it on mount, so recents paint instantly on F5 / route revisit; cached items are shape-validated and dropped if malformed
- Optimistic updates on rename and delete (apply locally + cache before background refresh)
- Visible toast on rename/delete failure instead of swallowed errors

Layout:
- Redesigned right sidebar
- Further left sidebar adjustments
- Updated chat content layout; chat and content area slightly widened
- Sidebar scrollbar covers recent chats only

Icons:
- Redesigned copy icon, unified across chat blocks and messages
- Redesigned trash icon to match
- Consistent icon sizing and style across chat and sidebar
- Adjusted icon colors across chat
- Fix icon on-hover background design

Chat messages:
- Menu icons now appear on hover over the menu area, not just the message
- Assistant message menu icons always visible; user messages keep on-hover (next/previous response stays visible for edited prompts)
- Repositioned and restyled generation speed info text below messages
- Restyled token generation speed popup

Tooltips:
- Removed tooltip on hover for previous/next assistant response icons
- Unified tooltip design across sidebars and chat
- Removed tooltip animations (also fixes related lag)

Model & Chat Template config:
- Merged Chat Template config into Model Configuration section
- Added revert-to-original for chat template
- Fix Chat Template config disappearing on page refresh until model reload

Performance & scroll:
- Removed chatbox movement animations across pages/navigation (fixes related UI lag)
- Fix scroll flicker at end of streaming when a code block is the final element
- Additional chat scroll improvements

Bug fixes:
- Fix 'more' button tooltip remaining visible after clicking elsewhere

* Remove sidebar localStorage cache and optimistic updates

Drops the localStorage hydration and optimistic rename/delete logic from the recents sidebar; reverts to fetching fresh on mount.

* Fix missing cn import in shared-composer (regression from merge)

* chore(sidebar): import sidebar deps from feature indexes

Re-export deleteChatItem / renameChatItem / useChatSidebarItems / SidebarItem / useChatSearchStore / ChatSearchDialog from @/features/chat, and removeTrainingUnloadGuard from @/features/training. Switch app-sidebar.tsx to consume them via the public feature indexes instead of deep paths, clearing the no-restricted-imports eslint errors. No behavior or UX change.

* fix(studio/frontend): reload training Recents sidebar after F5 refresh

The Recents sidebar showed empty after a hard refresh. The hook's inFlightRef dedup guard collided with React StrictMode's double-mount in dev: the second mount's fetch returned silently with no error, no retry, and no toast — leaving the sidebar empty until navigation.

Replace skip-if-busy dedup with abort-previous via a hook-level AbortController. This also fixes a latent race where a slow poll could resurrect a just-deleted row by clobbering the optimistic update.

Changes (all in use-training-history-sidebar.ts):
- fetchRuns aborts any in-flight request before starting a new one; post-await signal.aborted check drops stale responses.
- Optimistic helpers (applyRunUpdate, removeRun) abort in-flight fetches so they don't depend on caller discipline to invalidate stale data.
- Initial load gets bounded retry-with-backoff (500ms / 1.5s / 3.5s) and surfaces a sonner toast with a Retry action on final failure.
- Failure toast auto-dismisses on any successful load (initial retry, Retry click, or polling recovery).
- Polling pauses while the tab is hidden and catches up on visible, avoiding wasted requests during long training runs.
- Both effects own their teardown explicitly (abort + clear timer).

* Apply unified tooltip design and behavior across remaining pages for consistency

* UI polish: spacing, tooltip on source icons, letter spacing, smaller icons, consistent edit icon

- Adjust tiny spacing between elements around the UI for subtle polish
- Redesign tooltip on source icons for web search / tool use, consistent with the new design
- Adjust chat text letter spacing
- Smaller icon sizes
- Replace 'edit message' icon in chat with the new Rename icon used in Recents for consistency

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

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

* Adjust CSS for right sidebar

* Fix scrollbar UI compatibility across browsers

* fix: preserve chat preset settings on model load

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

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

* fix(studio): remove duplicate chat template status field

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

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

* chore: remove creative preset assumption

* fix(studio): align speculative decoding default

* fix(studio/chat): snap numeric param inputs to step grid

- Type a value in any param input (Temperature, Top K, Max Tokens, etc.)
  now clamps to [min, max] and snaps to the slider's step grid, killing
  off-grid values like 1.051234 and FP residue from slider drags.
- Branch picker chevrons share the action bar's 32px height + 10px radius
  via a new .aui-branch-chevron-btn utility; hover area aligns visually
  while staying narrower than the sibling icon buttons.

* fix(studio/chat): keep training-run polls converging and drop dead preset code

- Keep training-run polls converging when responses outrun the 5s interval
  (don't unconditionally abort prior in-flight; skip if one is still pending,
  mutation race still guarded).
- Drop dead Creative/Precise preset code paths (remove 'builtin-fixed' source
  variant + unreachable branches).

* fix(studio): training-run cards show custom name + model + dataset

- Training-run cards now display custom display_name + model + dataset,
  with cross-view sync on rename/delete.
- Enhance clarity of borders and colors in dark theme on export etc.

* fix(studio): match active state green to unsloth brand color

* fix(studio): preserve can_resume on training rename

* fix(studio): keep GGUF chat template override distinct

* fix(studio): treat audio input models as multimodal

* fix(studio): cancel numeric draft on Escape

* fix(studio): use default speculative mode on toggle

* fix(studio): detect GGUF audio VLM input models

* fix(studio): address final PR review findings

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

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

* fix(studio): refresh sidebar/history when a new training run starts so it appears without a manual reload

* fix: API and svg

* fix(studio/sidebar): align run rename dirty check with displayed baseline

* fix(studio/sidebar): use leading-tight on account block to prevent descender clipping with truncate

---------

Co-authored-by: sneakr <hauzin@hotmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: shine1i <wasimysdev@gmail.com>
2026-05-07 14:33:31 +04:00

294 lines
9.3 KiB
Python

import json
import os
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
WORKDIR = Path(__file__).resolve().parents[2]
def _source_path(relative_path: str) -> Path:
direct = WORKDIR / relative_path
if direct.exists():
return direct
return WORKDIR / "unsloth_repo" / relative_path
PRESET_POLICY = _source_path(
"studio/frontend/src/features/chat/presets/preset-policy.ts"
)
RUNTIME_TYPES = _source_path("studio/frontend/src/features/chat/types/runtime.ts")
TEMP = WORKDIR / "temp" / "chat_preset_builtin_invariants"
def _require_node():
if shutil.which("node") is None:
pytest.skip("node not available")
if not PRESET_POLICY.exists() or not RUNTIME_TYPES.exists():
pytest.skip("studio chat sources not present")
result = subprocess.run(
["node", "--experimental-strip-types", "--version"],
capture_output = True,
text = True,
timeout = 5,
)
if result.returncode != 0:
pytest.skip("node --experimental-strip-types not available")
def _ensure_harness():
TEMP.mkdir(parents = True, exist_ok = True)
(TEMP / "register.mjs").write_text(
"import { register } from 'node:module';\n"
"register('./loader.mjs', import.meta.url);\n"
)
(TEMP / "loader.mjs").write_text(
"export function resolve(specifier, context, next) {\n"
" if (specifier.endsWith('/types/runtime')) return next(specifier + '.ts', context);\n"
" return next(specifier, context);\n"
"}\n"
)
def _run(script: str):
_require_node()
_ensure_harness()
script_path = TEMP / "run.mts"
script_path.write_text(script)
env = dict(os.environ, NODE_NO_WARNINGS = "1")
result = subprocess.run(
[
"node",
"--experimental-strip-types",
"--import=./register.mjs",
"--no-warnings",
"run.mts",
],
cwd = str(TEMP),
capture_output = True,
text = True,
timeout = 30,
env = env,
)
assert result.returncode == 0, f"stderr: {result.stderr}\nstdout: {result.stdout}"
last = [line for line in result.stdout.strip().splitlines() if line.strip()][-1]
return json.loads(last)
def _policy_path():
return os.path.relpath(PRESET_POLICY, TEMP).replace("\\", "/")
def _runtime_path():
return os.path.relpath(RUNTIME_TYPES, TEMP).replace("\\", "/")
def test_default_builtin_matches_default_inference_params():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
import {{ DEFAULT_INFERENCE_PARAMS }} from "{_runtime_path()}";
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
console.log(JSON.stringify({{
found: !!def,
matches: def ? isSamePresetConfig(def.params, DEFAULT_INFERENCE_PARAMS) : null,
}}));
"""
)
)
assert out["found"] is True
assert out["matches"] is True
def test_is_same_preset_config_detects_temperature_edit():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
const edited = {{ ...def.params, temperature: def.params.temperature + 0.1 }};
console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, edited) }}));
"""
)
)
assert out["same"] is False
def test_is_same_preset_config_detects_system_prompt_edit():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
const edited = {{ ...def.params, systemPrompt: "you are a pirate" }};
console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, edited) }}));
"""
)
)
assert out["same"] is False
def test_is_same_preset_config_ignores_checkpoint_difference():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
const withCheckpoint = {{ ...def.params, checkpoint: "meta-llama/Llama-3-8B" }};
console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, withCheckpoint) }}));
"""
)
)
assert out["same"] is True
def test_is_same_preset_config_ignores_model_owned_fields():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
const edited = {{
...def.params,
maxSeqLength: def.params.maxSeqLength + 1024,
trustRemoteCode: !def.params.trustRemoteCode,
}};
console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, edited) }}));
"""
)
)
assert out["same"] is True
def test_preset_owned_config_key_ignores_model_owned_fields():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ BUILTIN_PRESETS, getPresetOwnedConfigKey }} from "{_policy_path()}";
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
const edited = {{
...def.params,
checkpoint: "foo/bar",
maxSeqLength: def.params.maxSeqLength + 1024,
trustRemoteCode: !def.params.trustRemoteCode,
}};
console.log(JSON.stringify({{
same: getPresetOwnedConfigKey(def.params) === getPresetOwnedConfigKey(edited),
}}));
"""
)
)
assert out["same"] is True
def test_to_preset_params_strips_model_owned_fields():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ toPresetParams }} from "{_policy_path()}";
const sanitized = toPresetParams({{
temperature: 0.9,
topP: 0.8,
topK: 40,
minP: 0.05,
repetitionPenalty: 1.1,
presencePenalty: 0.4,
maxSeqLength: 16384,
maxTokens: 2048,
systemPrompt: "hello",
checkpoint: "foo/bar",
trustRemoteCode: true,
}});
console.log(JSON.stringify({{
checkpoint: sanitized.checkpoint,
trustRemoteCode: sanitized.trustRemoteCode,
maxSeqLength: sanitized.maxSeqLength,
maxTokens: sanitized.maxTokens,
systemPrompt: sanitized.systemPrompt,
}}));
"""
)
)
assert out["checkpoint"] == ""
assert out["trustRemoteCode"] is False
assert out["maxSeqLength"] == 4096
assert out["maxTokens"] == 2048
assert out["systemPrompt"] == "hello"
def test_apply_preset_params_preserves_model_owned_fields():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ applyPresetParams }} from "{_policy_path()}";
const samplingPreset = {{
temperature: 1.5,
topP: 1,
topK: 0,
minP: 0.1,
repetitionPenalty: 1,
presencePenalty: 0,
maxSeqLength: 4096,
maxTokens: 2048,
systemPrompt: "",
checkpoint: "",
trustRemoteCode: false,
}};
const applied = applyPresetParams(
{{
temperature: 0.6,
topP: 0.95,
topK: 20,
minP: 0.01,
repetitionPenalty: 1.0,
presencePenalty: 0.0,
maxSeqLength: 16384,
maxTokens: 8192,
systemPrompt: "keep me?",
checkpoint: "foo/bar",
trustRemoteCode: true,
}},
samplingPreset,
);
console.log(JSON.stringify({{
checkpoint: applied.checkpoint,
trustRemoteCode: applied.trustRemoteCode,
maxSeqLength: applied.maxSeqLength,
temperature: applied.temperature,
topK: applied.topK,
}}));
"""
)
)
assert out["checkpoint"] == "foo/bar"
assert out["trustRemoteCode"] is True
assert out["maxSeqLength"] == 16384
assert out["temperature"] == 1.5
assert out["topK"] == 0
def test_default_is_only_builtin_preset():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ BUILTIN_PRESETS }} from "{_policy_path()}";
console.log(JSON.stringify({{
names: BUILTIN_PRESETS.map((p) => p.name),
}}));
"""
)
)
assert out["names"] == ["Default"]