From cc8599207cce0d22d52a46bf4a26bab10b8cfbf9 Mon Sep 17 00:00:00 2001 From: Long Yixing Date: Tue, 14 Jul 2026 01:42:40 +0800 Subject: [PATCH 001/329] Fix Studio user-message overflow for long unbroken text (#7100) --- studio/frontend/src/components/assistant-ui/thread.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index d987092c48..230a1fb40e 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -4006,7 +4006,7 @@ const UserMessage: FC = () => {
-
+
From 2573dbdd6bfc74ce12bb6ca64dbbc85117bc86ae Mon Sep 17 00:00:00 2001 From: Long Yixing Date: Tue, 14 Jul 2026 02:08:45 +0800 Subject: [PATCH 002/329] fix(studio): use writable recipe artifact path (#7044) --- studio/backend/core/data_recipe/service.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/studio/backend/core/data_recipe/service.py b/studio/backend/core/data_recipe/service.py index 9d8ca5cfcc..4647dc098d 100644 --- a/studio/backend/core/data_recipe/service.py +++ b/studio/backend/core/data_recipe/service.py @@ -9,6 +9,8 @@ import os from pathlib import Path from typing import Any +from utils.paths import recipe_datasets_root + from .jsonable import to_jsonable from .local_callable_validators import ( register_oxc_local_callable_validators, @@ -277,6 +279,11 @@ def create_data_designer(recipe: dict[str, Any], *, artifact_path: str | None = _apply_data_designer_image_context_patch() from data_designer.interface.data_designer import DataDesigner # pyright: ignore[reportMissingImports] + if artifact_path is None: + # DataDesigner defaults to cwd/artifacts; packaged Studio can run with + # cwd=/, so keep default callers on Studio's writable recipe artifact root. + artifact_path = str(recipe_datasets_root()) + recipe = _strip_frontend_model_config_metadata(recipe) model_providers = build_model_providers(recipe) _validate_recipe_runtime_support(recipe, model_providers) From a337c72753b2aba50a19613c751d15e315ad1cac Mon Sep 17 00:00:00 2001 From: Long Yixing Date: Tue, 14 Jul 2026 02:13:50 +0800 Subject: [PATCH 003/329] Fix Studio auto-titles for reasoning models (#7098) --- .../src/features/chat/runtime-provider.tsx | 9 +++-- tests/studio/test_chat_title_generation.py | 36 ++++++++++++++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index b545695f9e..634e5ec8b0 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -77,6 +77,7 @@ const pendingRunStartReadyByMessageId = new Map>(); type TitleResponse = { choices?: Array<{ + finish_reason?: string | null; message?: { content?: string; }; @@ -474,6 +475,8 @@ async function generateTitleWithModel(payload: { max_tokens: 24, top_k: 20, repetition_penalty: 1.0, + enable_thinking: false, + reasoning_effort: "none", messages: [ { role: "system", @@ -489,8 +492,10 @@ async function generateTitleWithModel(payload: { .json() .catch(() => null)) as TitleResponse | null; if (!response.ok) return null; - const raw: string | undefined = body?.choices?.[0]?.message?.content; - if (!raw) return null; + const choice = body?.choices?.[0]; + if (choice?.finish_reason === "length") return null; + const raw: string | undefined = choice?.message?.content; + if (!raw || /<\/?think>/i.test(raw)) return null; return normalizeTitle(raw); } diff --git a/tests/studio/test_chat_title_generation.py b/tests/studio/test_chat_title_generation.py index 4f82b4ec03..b568a51400 100644 --- a/tests/studio/test_chat_title_generation.py +++ b/tests/studio/test_chat_title_generation.py @@ -65,6 +65,8 @@ def test_title_model_payload_includes_optional_assistant_reply(): assert "if (assistant)" in block assert "parts.push(`Assistant: ${assistant}`);" in block assert 'parts.join("\\n")' in block + assert "enable_thinking: false" in block + assert 'reasoning_effort: "none"' in block def test_generate_title_passes_first_assistant_reply_after_first_user(): @@ -81,6 +83,25 @@ def test_generate_title_passes_first_assistant_reply_after_first_user(): assert "assistantText," in block +def test_tool_call_only_first_assistant_still_uses_first_user_message(): + source = RUNTIME_TSX.read_text() + extract_block = " ".join(_balanced_block(source, "function extractTextParts").split()) + generate_block = " ".join(_balanced_block(source, "async generateTitle(remoteId").split()) + + assert ( + '.filter((p): p is Extract => p.type === "text")' + in extract_block + ) + assert ( + "const userText = extractTextParts(firstUser) || defaultTitle; const assistantText = extractTextParts(firstAssistant);" + in generate_block + ) + assert ( + "(await generateTitleWithModel({ userText, assistantText, })) || fallbackTitleFromUserText(userText);" + in generate_block + ) + + def test_auto_title_disabled_uses_deterministic_user_text_fallback(): block = _balanced_block( RUNTIME_TSX.read_text(), @@ -93,12 +114,18 @@ def test_auto_title_disabled_uses_deterministic_user_text_fallback(): def test_model_failure_still_falls_back_to_user_text(): - block = _balanced_block( - RUNTIME_TSX.read_text(), - "async generateTitle(remoteId", + source = RUNTIME_TSX.read_text() + model_block = _source_until( + source, + "async function generateTitleWithModel", + "\nconst inflightTitleByKey", ) + generate_block = _balanced_block(source, "async generateTitle(remoteId") - assert "})) || fallbackTitleFromUserText(userText);" in block + assert "finish_reason?: string | null;" in source + assert 'if (choice?.finish_reason === "length") return null;' in model_block + assert r"if (!raw || /<\/?think>/i.test(raw)) return null;" in model_block + assert "})) || fallbackTitleFromUserText(userText);" in generate_block def test_title_normalizer_still_enforces_output_constraints(): @@ -113,3 +140,4 @@ def test_title_normalizer_still_enforces_output_constraints(): assert 'replace(/[.!?:;,]+/g, " ")' in block assert 'title.split(" ").filter(Boolean).slice(0, 6)' in block assert "joined.length > 60" in block + assert "return normalizeTitle(raw);" in block From 85f5292097638d7f005945005db8b083c6794d3c Mon Sep 17 00:00:00 2001 From: oobabooga Date: Mon, 13 Jul 2026 15:43:13 -0300 Subject: [PATCH 004/329] Studio: resync model state after a llama.cpp update unloads it (#6998) --- .../src/components/llama-update-banner.tsx | 13 +- .../src/features/chat/chat-settings-sheet.tsx | 6 +- .../chat/hooks/use-chat-model-runtime.ts | 144 +++++++++++------- studio/frontend/src/features/chat/index.ts | 5 +- .../src/hooks/use-llama-update-check.ts | 137 +++++++++++++++-- 5 files changed, 231 insertions(+), 74 deletions(-) diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx index 840383de90..3db15ffe30 100644 --- a/studio/frontend/src/components/llama-update-banner.tsx +++ b/studio/frontend/src/components/llama-update-banner.tsx @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { Button } from "@/components/ui/button"; +import { resyncInferenceStatusAfterServerModelChange } from "@/features/chat"; import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check"; import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref"; import { toast } from "@/lib/toast"; @@ -81,9 +82,14 @@ export function LlamaUpdateBanner({ positioned = true, }: LlamaUpdateBannerProps): ReactElement | null { const showBannerPref = useShowLlamaUpdateBanner(); + // Not gated on showBannerPref: this hook instance is the app-wide listener + // for a cross-tab reload_required resync (the settings-sheet's own instance + // only runs during an MTP-fallback rebuild), so muting the banner must not + // also silence that resync -- it only suppresses the UI below. const { status, visible, applying, apply, dismiss, snooze } = useLlamaUpdateCheck({ - enabled: enabled && showBannerPref, + enabled, + onReloadRequired: resyncInferenceStatusAfterServerModelChange, }); async function handleUpdate() { @@ -102,7 +108,10 @@ export function LlamaUpdateBanner({ } const show = - visible && status != null && (status.update_available || applying); + showBannerPref && + visible && + status != null && + (status.update_available || applying); const sizeBytes = status?.update_size_bytes ?? null; const sizeLabel = sizeBytes && sizeBytes > 0 diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index ea6c409b40..a547b88e57 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -80,6 +80,7 @@ import { Fragment, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "@/lib/toast"; import { OpenAICodeExecSection } from "./components/openai-code-exec-section"; +import { resyncInferenceStatusAfterServerModelChange } from "./hooks/use-chat-model-runtime"; import { type ExternalProviderConfig, getExternalProviderApiKey, @@ -573,7 +574,10 @@ export function ChatSettingsPanel({ status: llamaUpdateStatus, applying: llamaUpdating, apply: applyLlamaUpdate, - } = useLlamaUpdateCheck({ enabled: mtpUpdatable }); + } = useLlamaUpdateCheck({ + enabled: mtpUpdatable, + onReloadRequired: resyncInferenceStatusAfterServerModelChange, + }); const handleMtpUpdate = useCallback(async () => { const result = await applyLlamaUpdate(); if (result.ok) { diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 0a4342f208..a659b7f83e 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -245,18 +245,98 @@ function getTrustRemoteCodeRequiredMessage(modelName: string): string { return `${modelName} was not loaded because its custom code was not approved. Load it again to review the code and approve it.`; } +/** + * Reconcile the chat runtime store against `/api/inference/status`: refresh the + * models/loras catalogs and either re-pin the active checkpoint or clear the + * loaded-model flags when nothing is loaded. Module-level so it can run outside + * a React render (e.g. the imperative resync below); `useChatModelRuntime.refresh` + * is a thin wrapper over it. External selections are left untouched since they + * have no backend mirror. + */ +async function syncInferenceStatusToStore(options?: { + signal?: AbortSignal; + includeLoras?: boolean; +}): Promise { + const signal = options?.signal; + const includeLoras = options?.includeLoras ?? true; + const { setModels, setLoras, setCheckpoint, setModelsError } = + useChatRuntimeStore.getState(); + setModelsError(null); + try { + const [listRes, statusRes, lorasRes] = await Promise.all([ + listModels(), + getInferenceStatus(), + includeLoras ? listLoras() : Promise.resolve(null), + ]); + + // Cancellation can land while the requests above are in flight. Bail + // before writing backend state back -- cancelLoading already cleared it. + if (signal?.aborted) return; + + setModels(listRes.models.map(toChatModelSummary)); + if (lorasRes) { + setLoras(lorasRes.loras.map(toLoraSummary)); + } + + const selectedCheckpoint = useChatRuntimeStore.getState().params.checkpoint; + const isExternalSelectionActive = isExternalModelId(selectedCheckpoint); + if (statusRes.active_model && !isExternalSelectionActive) { + const checkpointId = resolveInferenceCheckpointId(statusRes); + if (checkpointId) { + setCheckpoint(checkpointId, statusRes.gguf_variant); + applyActiveModelStatusToStore(statusRes, { + previousCheckpoint: selectedCheckpoint, + }); + // setModels(listRes...) above used catalog data, which omits audio + // capability. Re-apply live status so attach gates survive a refresh. + syncModelCapabilities(checkpointId, statusRes); + } + } else if (!statusRes.active_model && !isExternalSelectionActive) { + useChatRuntimeStore.setState({ + modelRequiresTrustRemoteCode: false, + loadedIsMultimodal: false, + loadedIsDiffusion: false, + }); + } + } catch (error) { + if (signal?.aborted) return; + const message = + error instanceof Error ? error.message : "Failed to load models"; + setModelsError(message); + toast.error("Failed to refresh models", { + description: message, + }); + } +} + +/** + * Reconcile the UI after the SERVER unloaded the active model out from under it + * (e.g. a llama.cpp update unloads the running model to swap the binary): the + * model selector drops to "select model" instead of pointing at a model that now + * 400s on send. Imperative so the global llama-update banner (which has no + * chat-runtime handle) can call it. + * + * Only a LOCAL selection points at the unloaded model. An external-provider + * selection has no llama.cpp mirror and still works, so clearing it (which also + * wipes its persisted id) would drop a valid, unrelated model; skip the clear so + * the refresh below leaves it intact. + */ +export async function resyncInferenceStatusAfterServerModelChange(): Promise { + if (!isExternalModelId(useChatRuntimeStore.getState().params.checkpoint)) { + useChatRuntimeStore.getState().clearCheckpoint(); + } + await syncInferenceStatusToStore(); +} + export function useChatModelRuntime() { const params = useChatRuntimeStore((state) => state.params); const models = useChatRuntimeStore((state) => state.models); const loras = useChatRuntimeStore((state) => state.loras); - const setModels = useChatRuntimeStore((state) => state.setModels); - const setLoras = useChatRuntimeStore((state) => state.setLoras); const setParams = useChatRuntimeStore((state) => state.setParams); const setModelsError = useChatRuntimeStore((state) => state.setModelsError); const setLastModelLoadError = useChatRuntimeStore( (state) => state.setLastModelLoadError, ); - const setCheckpoint = useChatRuntimeStore((state) => state.setCheckpoint); const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint); const [loadingModel, setLoadingModel] = useState<{ @@ -313,59 +393,11 @@ export function useChatModelRuntime() { [], ); - const refresh = useCallback(async (options?: { - signal?: AbortSignal; - includeLoras?: boolean; - }) => { - const signal = options?.signal; - const includeLoras = options?.includeLoras ?? true; - setModelsError(null); - try { - const [listRes, statusRes, lorasRes] = await Promise.all([ - listModels(), - getInferenceStatus(), - includeLoras ? listLoras() : Promise.resolve(null), - ]); - - // Cancellation can land while the requests above are in flight. Bail - // before writing backend state back -- cancelLoading already cleared it. - if (signal?.aborted) return; - - setModels(listRes.models.map(toChatModelSummary)); - if (lorasRes) { - setLoras(lorasRes.loras.map(toLoraSummary)); - } - - const selectedCheckpoint = useChatRuntimeStore.getState().params.checkpoint; - const isExternalSelectionActive = isExternalModelId(selectedCheckpoint); - if (statusRes.active_model && !isExternalSelectionActive) { - const checkpointId = resolveInferenceCheckpointId(statusRes); - if (checkpointId) { - setCheckpoint(checkpointId, statusRes.gguf_variant); - applyActiveModelStatusToStore(statusRes, { - previousCheckpoint: selectedCheckpoint, - }); - // setModels(listRes...) above used catalog data, which omits audio - // capability. Re-apply live status so attach gates survive a refresh. - syncModelCapabilities(checkpointId, statusRes); - } - } else if (!statusRes.active_model && !isExternalSelectionActive) { - useChatRuntimeStore.setState({ - modelRequiresTrustRemoteCode: false, - loadedIsMultimodal: false, - loadedIsDiffusion: false, - }); - } - } catch (error) { - if (signal?.aborted) return; - const message = - error instanceof Error ? error.message : "Failed to load models"; - setModelsError(message); - toast.error("Failed to refresh models", { - description: message, - }); - } - }, [setCheckpoint, setLoras, setModels, setModelsError, setParams]); + const refresh = useCallback( + (options?: { signal?: AbortSignal; includeLoras?: boolean }) => + syncInferenceStatusToStore(options), + [], + ); const cancelLoading = useCallback(() => { const model = loadingModelRef.current; diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index d070ed15de..3099884645 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -25,7 +25,10 @@ export { usePlusMenuPrefsStore, type PlusMenuItemId, } from "./stores/plus-menu-prefs-store"; -export { useChatModelRuntime } from "./hooks/use-chat-model-runtime"; +export { + useChatModelRuntime, + resyncInferenceStatusAfterServerModelChange, +} from "./hooks/use-chat-model-runtime"; export { customProviderDisplayName, isExternalModelId, diff --git a/studio/frontend/src/hooks/use-llama-update-check.ts b/studio/frontend/src/hooks/use-llama-update-check.ts index d8f894b1b1..e3a735ffde 100644 --- a/studio/frontend/src/hooks/use-llama-update-check.ts +++ b/studio/frontend/src/hooks/use-llama-update-check.ts @@ -22,6 +22,9 @@ export interface LlamaUpdateJob { error: string | null; // Download fraction while running, 1 on success. progress: number | null; + // Set once the job leaves "running"; identifies a completed job so a + // repeated fetch of the same success can be told apart from the next one. + finished_at: string | null; } export interface LlamaUpdateStatus { @@ -34,10 +37,24 @@ export interface LlamaUpdateStatus { job: LlamaUpdateJob; } +function parseJob(value: unknown): LlamaUpdateJob { + const job = (value ?? {}) as Record; + return { + state: (job.state as LlamaUpdateJob["state"]) ?? "idle", + message: typeof job.message === "string" ? job.message : "", + from_tag: typeof job.from_tag === "string" ? job.from_tag : null, + to_tag: typeof job.to_tag === "string" ? job.to_tag : null, + reload_required: + typeof job.reload_required === "boolean" ? job.reload_required : null, + error: typeof job.error === "string" ? job.error : null, + progress: typeof job.progress === "number" ? job.progress : null, + finished_at: typeof job.finished_at === "string" ? job.finished_at : null, + }; +} + function parseStatus(value: unknown): LlamaUpdateStatus | null { if (!value || typeof value !== "object") return null; const s = value as Record; - const job = (s.job ?? {}) as Record; return { supported: s.supported === true, update_available: s.update_available === true, @@ -45,19 +62,34 @@ function parseStatus(value: unknown): LlamaUpdateStatus | null { latest_tag: typeof s.latest_tag === "string" ? s.latest_tag : null, update_size_bytes: typeof s.update_size_bytes === "number" ? s.update_size_bytes : null, - job: { - state: (job.state as LlamaUpdateJob["state"]) ?? "idle", - message: typeof job.message === "string" ? job.message : "", - from_tag: typeof job.from_tag === "string" ? job.from_tag : null, - to_tag: typeof job.to_tag === "string" ? job.to_tag : null, - reload_required: - typeof job.reload_required === "boolean" ? job.reload_required : null, - error: typeof job.error === "string" ? job.error : null, - progress: typeof job.progress === "number" ? job.progress : null, - }, + job: parseJob(s.job), }; } +// The backend job persists as "success" until the next update starts (it's a +// single in-memory record, not per-tab), so a fresh mount -- a new tab, or a +// page reload of a tab that already resynced -- would otherwise replay the +// same completed job forever. Persist the handled marker outside React state +// so it survives both, and is shared across tabs in this browser. +const HANDLED_RELOAD_STORAGE_KEY = "unsloth_llama_update_reload_handled_at"; + +function getHandledReloadAt(): string | null { + try { + return localStorage.getItem(HANDLED_RELOAD_STORAGE_KEY); + } catch { + return null; + } +} + +function setHandledReloadAt(finishedAt: string | null): void { + if (!finishedAt) return; + try { + localStorage.setItem(HANDLED_RELOAD_STORAGE_KEY, finishedAt); + } catch { + // storage unavailable + } +} + async function fetchStatus( forceRefresh = false, ): Promise { @@ -78,6 +110,14 @@ const recheckStatus = () => fetchStatus(true); interface UseLlamaUpdateCheckOptions { enabled?: boolean; + /** + * Called when a completed update reports `reload_required` (i.e. it unloaded + * the active model server-side). Consumers use it to resync the chat runtime + * so the model selector drops to "select model" instead of pointing at a + * model that now 400s on send. Fires for both this tab's own apply() and a + * cross-tab update mirrored through the background poll. + */ + onReloadRequired?: () => void; } export interface LlamaApplyResult { @@ -90,12 +130,25 @@ export interface LlamaApplyResult { /** Tracks llama.cpp update visibility and apply progress. */ export function useLlamaUpdateCheck({ enabled = true, + onReloadRequired, }: UseLlamaUpdateCheckOptions = {}) { const [status, setStatus] = useState(null); const [visible, setVisible] = useState(false); const [applying, setApplying] = useState(false); const pollTimer = useRef | null>(null); const snoozeTimer = useRef | null>(null); + // Read through a ref so startJobPoll stays stable (apply/surfaceIfAvailable + // depend on it) while still calling the latest callback. + const onReloadRequiredRef = useRef(onReloadRequired); + useEffect(() => { + onReloadRequiredRef.current = onReloadRequired; + }, [onReloadRequired]); + // Fires the callback once per completed job, whether this tab watched it run + // or only saw the persisted "success" after the fact (e.g. another tab + // applied it). Keyed by finished_at and seeded from localStorage so a fresh + // mount (new tab, or a page reload of a tab that already resynced) doesn't + // replay a job some tab already handled. + const reloadNotifiedForRef = useRef(getHandledReloadAt()); const clearPollTimer = useCallback(() => { if (pollTimer.current) { @@ -104,6 +157,25 @@ export function useLlamaUpdateCheck({ } }, []); + // Shared by the poll path (this tab watched the job run), the surface path + // (this tab only saw the persisted success), and apply()'s stale-click path + // (the job came back embedded in a "not started" response) so none of them + // can drop or double-fire the notification. + const notifyReloadIfNeeded = useCallback( + (job: Pick) => { + if ( + job.state === "success" && + job.reload_required && + job.finished_at !== reloadNotifiedForRef.current + ) { + reloadNotifiedForRef.current = job.finished_at; + setHandledReloadAt(job.finished_at); + onReloadRequiredRef.current?.(); + } + }, + [], + ); + // Used by apply() and another-tab job tracking. const startJobPoll = useCallback( (onDone?: (result: LlamaApplyResult) => void) => { @@ -118,6 +190,12 @@ export function useLlamaUpdateCheck({ if (s.job.state === "success") { setVisible(false); void refreshHardwareInfo(); + // The update unloads the running model server-side, so the chat + // runtime still points at a model that now 400s on send. Let the + // consumer drop the selector to "select model" instead of waiting for + // a page reload. Fires here (not just from apply's onDone) so a + // cross-tab update mirrored through this poll is covered too. + notifyReloadIfNeeded(s.job); onDone?.({ ok: true, tag: s.job.to_tag, @@ -131,7 +209,7 @@ export function useLlamaUpdateCheck({ } }, JOB_POLL_INTERVAL_MS); }, - [clearPollTimer], + [clearPollTimer, notifyReloadIfNeeded], ); const surfaceIfAvailable = useCallback( @@ -145,11 +223,16 @@ export function useLlamaUpdateCheck({ if (!pollTimer.current) startJobPoll(); return; } + // A completed job persists as "success" until the next update starts, so + // a tab that missed the running window entirely (mounted, or only checks + // hourly and misses both the running and just-finished moments) still + // needs to resync here, not just from the poll path above. + notifyReloadIfNeeded(next.job); if (next.update_available) { setVisible(true); } }, - [startJobPoll], + [startJobPoll, notifyReloadIfNeeded], ); useEffect(() => { @@ -185,6 +268,26 @@ export function useLlamaUpdateCheck({ }; }, [enabled, surfaceIfAvailable, clearPollTimer]); + // Cross-tab nudge: a tab that only checks hourly would otherwise stay + // pointed at a server-unloaded model for up to an hour after a DIFFERENT + // open tab applies an update. The storage event only fires in other tabs + // (never the one that wrote it), so this recheck fires promptly there + // without this tab redundantly re-triggering itself. + useEffect(() => { + if (!enabled) return; + const onStorage = (event: StorageEvent) => { + if ( + event.key === HANDLED_RELOAD_STORAGE_KEY && + event.newValue && + event.newValue !== reloadNotifiedForRef.current + ) { + recheckStatus().then(surfaceIfAvailable); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [enabled, surfaceIfAvailable]); + const dismiss = useCallback(() => { setVisible(false); }, []); @@ -206,6 +309,7 @@ export function useLlamaUpdateCheck({ started?: boolean; reason?: string | null; message?: string | null; + job?: unknown; } | null = null; try { const res = await authFetch("/api/llama/update", { method: "POST" }); @@ -229,6 +333,11 @@ export function useLlamaUpdateCheck({ action.started === false && action.reason !== "already_running" ) { + // A stale banner's click can land after another tab already applied the + // update (e.g. "up_to_date"): the response still carries that tab's + // completed job, so process reload_required here too, not just from the + // poll path -- otherwise this rejection silently drops it. + notifyReloadIfNeeded(parseJob(action.job)); setApplying(false); return { ok: false, @@ -239,7 +348,7 @@ export function useLlamaUpdateCheck({ return await new Promise((resolve) => startJobPoll(resolve), ); - }, [applying, startJobPoll]); + }, [applying, startJobPoll, notifyReloadIfNeeded]); return { status: enabled ? status : null, From f60b982a09ed258b9469ef1cbca54c466d9f0cb1 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Mon, 13 Jul 2026 17:34:25 -0300 Subject: [PATCH 005/329] Studio: Fix torch_dtype deprecation warning on startup and ASR load (#6999) --- studio/backend/core/inference/inference.py | 3 +- studio/backend/core/rag/embeddings.py | 5 +- .../backend/tests/test_transformers_dtype.py | 77 +++++++++++++++++++ studio/backend/utils/transformers_dtype.py | 51 ++++++++++++ 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 studio/backend/tests/test_transformers_dtype.py create mode 100644 studio/backend/utils/transformers_dtype.py diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 7e69e05124..bb4be39ef6 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import Optional, Union, Generator, Tuple from utils.models import ModelConfig, get_base_model_from_lora from utils.paths import is_model_cached +from utils.transformers_dtype import dtype_kwargs from utils.utils import format_error_message from utils.hardware import ( get_device, @@ -440,7 +441,7 @@ class InferenceBackend: feature_extractor = tokenizer.feature_extractor, processor = tokenizer, return_language = True, - torch_dtype = torch.float16, + **dtype_kwargs(torch.float16), ) self.models[model_name]["model"] = model self.models[model_name]["tokenizer"] = tokenizer diff --git a/studio/backend/core/rag/embeddings.py b/studio/backend/core/rag/embeddings.py index 47d26209b4..b0ecedd593 100644 --- a/studio/backend/core/rag/embeddings.py +++ b/studio/backend/core/rag/embeddings.py @@ -21,6 +21,7 @@ from functools import lru_cache from typing import Callable from utils.hardware.hardware import DeviceType, get_device +from utils.transformers_dtype import dtype_kwargs from . import config @@ -157,9 +158,7 @@ def _get(model_name: str | None = None): device = _device() logger.info("loading embedding model %s on %s", name, device) _guard_model_security(name) - _model = SentenceTransformer( - name, device = device, model_kwargs = {"torch_dtype": "float16"} - ) + _model = SentenceTransformer(name, device = device, model_kwargs = dtype_kwargs("float16")) _name = name return _model diff --git a/studio/backend/tests/test_transformers_dtype.py b/studio/backend/tests/test_transformers_dtype.py new file mode 100644 index 0000000000..28629e5620 --- /dev/null +++ b/studio/backend/tests/test_transformers_dtype.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for the version-safe torch_dtype/dtype kwarg helper.""" + +import sys +import types + +import pytest + +from utils.transformers_dtype import _has_torch_dtype_kwarg, dtype_kwargs + + +@pytest.fixture(autouse = True) +def _clear_cache(): + _has_torch_dtype_kwarg.cache_clear() + yield + _has_torch_dtype_kwarg.cache_clear() + + +def _stub_transformers(monkeypatch, version): + stub = types.ModuleType("transformers") + stub.__version__ = version + monkeypatch.setitem(sys.modules, "transformers", stub) + + +def test_old_transformers_uses_torch_dtype(monkeypatch): + _stub_transformers(monkeypatch, "4.51.3") + assert _has_torch_dtype_kwarg() is True + assert dtype_kwargs("float16") == {"torch_dtype": "float16"} + + +def test_new_transformers_uses_dtype(monkeypatch): + _stub_transformers(monkeypatch, "4.57.6") + assert _has_torch_dtype_kwarg() is False + assert dtype_kwargs("float16") == {"dtype": "float16"} + + +def test_rename_boundary_uses_dtype(monkeypatch): + _stub_transformers(monkeypatch, "4.56.0") + assert _has_torch_dtype_kwarg() is False + + +def test_just_below_boundary_uses_torch_dtype(monkeypatch): + _stub_transformers(monkeypatch, "4.55.4") + assert _has_torch_dtype_kwarg() is True + + +@pytest.mark.parametrize("version", ["4.56.0.dev0", "4.56.0rc1"]) +def test_rename_prerelease_uses_dtype(monkeypatch, version): + """A pre-release of the rename version sorts *below* ``4.56.0`` but already + accepts (and prefers) ``dtype``; the release-tuple check must not fall back to + the legacy name there, or it re-emits the deprecation warning it suppresses.""" + _stub_transformers(monkeypatch, version) + assert _has_torch_dtype_kwarg() is False + + +def test_malformed_version_prefers_modern_name(monkeypatch): + """A non-PEP440 __version__ raises InvalidVersion; the except branch must + swallow it and default to the modern name rather than crash the embedder warm-up.""" + _stub_transformers(monkeypatch, "not-a-version") + assert _has_torch_dtype_kwarg() is False + assert dtype_kwargs("float16") == {"dtype": "float16"} + + +def test_missing_transformers_prefers_modern_name(monkeypatch): + monkeypatch.delitem(sys.modules, "transformers", raising = False) + real_import = __import__ + + def _raise(name, *args, **kwargs): + if name == "transformers": + raise ImportError("no transformers") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", _raise) + assert _has_torch_dtype_kwarg() is False + assert dtype_kwargs("float16") == {"dtype": "float16"} diff --git a/studio/backend/utils/transformers_dtype.py b/studio/backend/utils/transformers_dtype.py new file mode 100644 index 0000000000..daeb6e2452 --- /dev/null +++ b/studio/backend/utils/transformers_dtype.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Version-safe fp-dtype kwarg for transformers/sentence-transformers loads. + +transformers renamed the ``torch_dtype`` kwarg to ``dtype`` in 4.56.0, and emits +``torch_dtype is deprecated! Use dtype instead!`` when the old name is passed. But our floor (``transformers>=4.51.3``) predates ``dtype`` and only +accepts ``torch_dtype``, so a bare rename would ``TypeError`` on the floor. Pick +the name the installed version accepts instead. + +Answers the same question as ``unsloth_zoo.hf_utils.HAS_TORCH_DTYPE`` but derives +it independently, for two reasons. It uses a ``packaging.version`` check rather +than that constant's ``"torch_dtype" in PretrainedConfig.__doc__`` sniffing, which +raises ``TypeError`` under ``python -OO`` / ``PYTHONOPTIMIZE=2`` (docstrings are +stripped to ``None``, and ``"torch_dtype" in None`` is a type error). And it avoids +importing the constant at all: the RAG +embedder warms here at startup in the lean main process, and reading it would run +``unsloth_zoo``'s package ``__init__`` (torch import, GPU/Pytorch checks, the +patching banner) as a side effect. The embedder is deliberately torch-optional (it +degrades to the ``llama-server`` GGUF backend), so it must not drag in that +heavyweight import just to read one bool. +""" + +from functools import lru_cache + + +@lru_cache(maxsize = 1) +def _has_torch_dtype_kwarg() -> bool: + """True if the installed transformers still expects the legacy ``torch_dtype`` + name (i.e. predates the ``dtype`` rename). False when ``dtype`` is the accepted + name, or when transformers is missing/broken (prefer the modern name).""" + try: + import transformers + from packaging.version import Version + + # Compare on the release tuple so a pre-release of the rename version + # (``4.56.0.dev0``/``rc1``, which sort *below* ``4.56.0``) still counts as + # new and picks ``dtype`` -- those builds already accept it, and picking + # ``torch_dtype`` there would re-emit the very warning this suppresses. + return Version(transformers.__version__).release < (4, 56, 0) + except Exception: + return False + + +def dtype_kwargs(value) -> dict: + """``{"torch_dtype": value}`` on old transformers, ``{"dtype": value}`` on new. + + Splat into a load call (``pipeline(..., **dtype_kwargs(torch.float16))``) or use + directly as ``model_kwargs`` (``model_kwargs = dtype_kwargs("float16")``). + """ + return {"torch_dtype" if _has_torch_dtype_kwarg() else "dtype": value} From 76d7088e0ae032f537eca2a781e1b7578d2701f5 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Mon, 13 Jul 2026 19:18:20 -0300 Subject: [PATCH 006/329] Studio: Show Run button for downloaded non-GGUF models in the Model Hub (#7001) --- .../hub/catalog/local-on-device-card.tsx | 3 +- .../features/hub/catalog/model-inspector.tsx | 39 +++++++++++++++++-- .../hub/catalog/safetensors-download-card.tsx | 16 +++++--- .../src/features/hub/lib/hub-feature-flags.ts | 9 ++++- .../src/features/hub/lib/unsloth-support.ts | 3 ++ 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/studio/frontend/src/features/hub/catalog/local-on-device-card.tsx b/studio/frontend/src/features/hub/catalog/local-on-device-card.tsx index e40f5fd5ac..3c020a7199 100644 --- a/studio/frontend/src/features/hub/catalog/local-on-device-card.tsx +++ b/studio/frontend/src/features/hub/catalog/local-on-device-card.tsx @@ -4,6 +4,7 @@ import { TrainIcon } from "../components/train-icon"; import { HUB_GGUF_RUN_ACTIONS_VISIBLE, + HUB_NON_GGUF_RUN_ACTIONS_VISIBLE, HUB_POST_DOWNLOAD_ACTIONS_VISIBLE, } from "../lib/hub-feature-flags"; import { @@ -410,7 +411,7 @@ export function LocalOnDeviceCard({ const showOldCacheHint = source === "hf_cache" && !!unsupportedReason; const runActionsVisible = isGguf ? HUB_GGUF_RUN_ACTIONS_VISIBLE - : HUB_POST_DOWNLOAD_ACTIONS_VISIBLE; + : HUB_NON_GGUF_RUN_ACTIONS_VISIBLE; return (
diff --git a/studio/frontend/src/features/hub/catalog/model-inspector.tsx b/studio/frontend/src/features/hub/catalog/model-inspector.tsx index 0f244bce53..a67b2a57cc 100644 --- a/studio/frontend/src/features/hub/catalog/model-inspector.tsx +++ b/studio/frontend/src/features/hub/catalog/model-inspector.tsx @@ -263,17 +263,22 @@ type VramInfo = { est: number; status: "fits" | "tight" | "exceeds" } | null; function ModelStatusChips({ isDataset, isGguf, + chatOnly, unslothSupport, vramInfo, }: { isDataset: boolean; isGguf: boolean; + chatOnly: boolean; unslothSupport: UnslothSupport; vramInfo: VramInfo; }) { const showUnsupported = !isDataset && unslothSupport.status === "unsupported"; + // The format-unsupported chip already explains itself; this one covers the + // supported-format model a chat-only host still can't run. + const showChatOnly = !isDataset && !isGguf && chatOnly && !showUnsupported; const showVram = !isDataset && vramInfo && !isGguf; - if (!showUnsupported && !showVram) return null; + if (!showUnsupported && !showChatOnly && !showVram) return null; const vramTone = vramInfo ? vramInfo.status === "exceeds" @@ -323,6 +328,26 @@ function ModelStatusChips({ )} + {showChatOnly && ( + + + + + + + + This device has no supported GPU or usable MLX, so only GGUF models + can run here. + + Still downloadable to your Hugging Face cache. + + + + )} {showVram && vramInfo && ( @@ -406,6 +431,7 @@ export const ModelInspector = memo(function ModelInspector({ onSearchHub, } = actions; const deviceType = usePlatformStore((s) => s.deviceType); + const chatOnly = usePlatformStore((s) => s.isChatOnly()); const hfToken = useHfTokenStore((s) => s.token); const datasetRepoId = isDataset && model?.hubRepoId ? model.hubRepoId : null; const datasetSize = useDatasetSize(datasetRepoId, { @@ -504,15 +530,19 @@ export const ModelInspector = memo(function ModelInspector({ const paramsLabel = model.totalParams ? formatCompact(model.totalParams) : "N/A"; - const trainingSupported = unslothSupport.status !== "unsupported"; + const unslothSupported = unslothSupport.status !== "unsupported"; + // Chat-only hosts (no supported GPU / usable MLX) run inference only through + // llama.cpp, so only GGUF is loadable. const canRunModel = - !isDataset && (model.runtimeCapabilities?.canChat ?? true); + !isDataset && + (model.runtimeCapabilities?.canChat ?? true) && + (model.isGguf || (!chatOnly && unslothSupported)); const canTrainModel = !isDataset && (model.runtimeCapabilities?.canTrain ?? false) && model.modelFormat !== "gguf" && model.modelFormat !== "adapter" && - trainingSupported; + unslothSupported; const languages = parseLanguageTags(model.tags); const datasetSizeBytes = @@ -765,6 +795,7 @@ export const ModelInspector = memo(function ModelInspector({ diff --git a/studio/frontend/src/features/hub/catalog/safetensors-download-card.tsx b/studio/frontend/src/features/hub/catalog/safetensors-download-card.tsx index ccd61c5f04..424afa2e05 100644 --- a/studio/frontend/src/features/hub/catalog/safetensors-download-card.tsx +++ b/studio/frontend/src/features/hub/catalog/safetensors-download-card.tsx @@ -16,7 +16,10 @@ import { PlayIcon, } from "@hugeicons/core-free-icons"; import { TrainIcon } from "../components/train-icon"; -import { HUB_POST_DOWNLOAD_ACTIONS_VISIBLE } from "../lib/hub-feature-flags"; +import { + HUB_NON_GGUF_RUN_ACTIONS_VISIBLE, + HUB_POST_DOWNLOAD_ACTIONS_VISIBLE, +} from "../lib/hub-feature-flags"; import { HugeiconsIcon } from "@hugeicons/react"; import { useEffect, useState } from "react"; import { useHfTokenStore } from "../stores/hf-token-store"; @@ -158,6 +161,7 @@ export function SafetensorsDownloadCard({ const showActionPair = isDownloaded && !downloading && (canRun || !!onTrain); const showUnavailableAction = isDownloaded && !downloading && !canRun && !onTrain; + const trainActionVisible = !!onTrain && HUB_POST_DOWNLOAD_ACTIONS_VISIBLE; const canDelete = (isDownloaded || isPartial) && !downloading && @@ -238,17 +242,17 @@ export function SafetensorsDownloadCard({ )}
- {/* Divider sits above the Download CTA; in the action-pair state it hides with the pair. */} - {(!showActionPair || HUB_POST_DOWNLOAD_ACTIONS_VISIBLE) && } + {/* Info/actions hairline; dropped for the run action row (no divider before + Run, as in the GGUF card's Run CTA), restored when the Train pair ships. */} + {(!showActionPair || trainActionVisible) && } {showActionPair ? ( From ed427027305ca2eaee3ad4488621a98a19c2259b Mon Sep 17 00:00:00 2001 From: oobabooga Date: Tue, 14 Jul 2026 00:01:25 -0300 Subject: [PATCH 009/329] Probe xformers support on sm_120 instead of disabling it by version (#6828) --- .../test_attention_dispatch_dora_dtype.py | 56 ++++++++++ tests/utils/test_xformers_capability_gate.py | 102 ++++++++++++++++++ unsloth/utils/attention_dispatch.py | 66 +++++++++--- 3 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 tests/utils/test_xformers_capability_gate.py diff --git a/tests/utils/test_attention_dispatch_dora_dtype.py b/tests/utils/test_attention_dispatch_dora_dtype.py index a6586dea8d..f0496ee6a8 100644 --- a/tests/utils/test_attention_dispatch_dora_dtype.py +++ b/tests/utils/test_attention_dispatch_dora_dtype.py @@ -71,3 +71,59 @@ def test_varlen_flash_downcasts_fp32_qkv(monkeypatch): def test_bf16_qkv_left_untouched(monkeypatch): # Standard LoRA path (already bf16) must not be altered. assert _run(monkeypatch, torch.bfloat16, ad.FLASH_DENSE) == torch.bfloat16 + + +def _run_xformers(monkeypatch, qkv_dtype, fp32_unsupported): + # Same #1013 fp32 downcast, but for the xformers backend. On sm_100+ (B200, sm_120) + # xformers' fp32-capable cutlass op is capability-rejected and only its flash-2 op + # runs (fp16/bf16 only), so fp32 must be downcast there too or the op raises. + captured = {} + + def fake_xformers_attention( + Q, + K, + V, + attn_bias = None, + **kwargs, + ): + captured["dtype"] = Q.dtype + # Mirror the flash-2 op's real dtype constraint so an unfixed dispatch fails loudly. + if fp32_unsupported and Q.dtype not in (torch.float16, torch.bfloat16): + raise RuntimeError("no operator found for memory_efficient_attention with fp32") + return torch.zeros_like(Q) + + monkeypatch.setattr(ad, "_XFORMERS_FP32_UNSUPPORTED", fp32_unsupported, raising = False) + monkeypatch.setattr(ad, "xformers_attention", fake_xformers_attention, raising = False) + monkeypatch.setattr( + ad, "build_xformers_block_causal_mask", lambda *a, **k: object(), raising = False + ) + + bsz, n_heads, q_len, head_dim = 1, 2, 4, 8 + Q = torch.randn(bsz, n_heads, q_len, head_dim, dtype = qkv_dtype) + K = torch.randn(bsz, n_heads, q_len, head_dim, dtype = qkv_dtype) + V = torch.randn(bsz, n_heads, q_len, head_dim, dtype = qkv_dtype) + + config = ad.AttentionConfig(backend = ad.XFORMERS, n_kv_heads = n_heads, n_groups = 1) + context = ad.AttentionContext( + bsz = bsz, + q_len = q_len, + kv_seq_len = q_len, + n_heads = n_heads, + head_dim = head_dim, + requires_grad = False, + seq_info = None, + attention_mask = None, + causal_mask = None, + ) + ad.run_attention(config = config, context = context, Q = Q, K = K, V = V) + return captured["dtype"] + + +def test_xformers_downcasts_fp32_qkv_on_sm100_plus(monkeypatch): + # sm_100+ (fp32 op gone): fp32 DoRA output must be downcast, else the flash-2 op raises. + assert _run_xformers(monkeypatch, torch.float32, True) in (torch.bfloat16, torch.float16) + + +def test_xformers_leaves_fp32_qkv_below_sm100(monkeypatch): + # Below sm_100 the cutlass op handles fp32 natively, so it must be passed through as-is. + assert _run_xformers(monkeypatch, torch.float32, False) == torch.float32 diff --git a/tests/utils/test_xformers_capability_gate.py b/tests/utils/test_xformers_capability_gate.py new file mode 100644 index 0000000000..7514c623e3 --- /dev/null +++ b/tests/utils/test_xformers_capability_gate.py @@ -0,0 +1,102 @@ +"""Regression test for unslothai/unsloth#4631: xformers must not be blanket-disabled +on sm_120 GPUs where its kernel actually runs (a ~57% attention-memory saving over the +SDPA packed-mask fallback). The gate now probes the real op instead of guessing by the +compute-capability major version.""" + +import pytest +import torch +import unsloth # noqa: F401 + +from unsloth.utils import attention_dispatch as ad + + +@pytest.mark.parametrize( + "capability, probe_result, expect_disabled", + [ + ((8, 9), None, False), # Ada: below sm_120, never probed, always kept + ((9, 0), None, False), # Hopper: below sm_120, kept + ((10, 0), None, False), # Blackwell B200 (sm_100): below sm_120, kept + ((12, 0), True, False), # sm_120 where the kernel runs: keep xformers + ((12, 0), False, True), # sm_120 where the kernel can't run: fall back to SDPA + ], +) +def test_capability_gate(capability, probe_result, expect_disabled): + calls = {"n": 0} + + def probe(): + calls["n"] += 1 + return probe_result + + assert ad._xformers_disabled_for_capability(capability, probe = probe) is expect_disabled + # Below sm_120 the probe must not run at all (no import-time kernel launch there). + assert calls["n"] == (0 if capability[0] < 12 else 1) + + +@pytest.mark.skipif( + not (torch.cuda.is_available() and ad.HAS_XFORMERS), + reason = "needs a CUDA GPU with a working xformers build", +) +@pytest.mark.skipif( + torch.cuda.is_available() and torch.cuda.get_device_capability()[0] >= 12, + reason = "on real sm_120+ the probe legitimately returns False when the build ships no " + "sm_120 kernel, so asserting True there would be a false failure", +) +def test_probe_shapes_are_valid_on_working_gpu(): + # Guards against a malformed probe that raises on every GPU and would silently + # disable xformers on Blackwell even where it works. On a pre-sm_120 GPU with a + # functional xformers the real probe must succeed; sm_120+ is skipped above because + # there a False is a correct answer, not a malformed probe. + assert ad._xformers_runs_on_device() is True + + +@pytest.mark.parametrize( + "supports_bf16, expected_dtype", + [(True, torch.bfloat16), (False, torch.float16)], +) +def test_probe_dtype_follows_bf16_support(monkeypatch, supports_bf16, expected_dtype): + # Pre-Ampere GPUs (sm < 80: Turing/Volta, e.g. T4/V100) run xformers fine in + # float16 but have no bfloat16 attention kernel, so a hardcoded bf16 probe would + # raise there, get swallowed to False, and misreport a working xformers as broken. + # The probe must pick its dtype from SUPPORTS_BFLOAT16 (no Turing GPU needed here). + captured = {} + + def fake_zeros( + *args, + dtype = None, + **kwargs, + ): + captured["dtype"] = dtype + raise RuntimeError("stop after capturing the probe dtype") + + monkeypatch.setattr(ad, "SUPPORTS_BFLOAT16", supports_bf16) + monkeypatch.setattr(ad.torch, "zeros", fake_zeros) + ad._xformers_runs_on_device() # RuntimeError is swallowed; only the dtype matters + assert captured["dtype"] is expected_dtype + + +def test_probe_syncs_and_fails_on_deferred_async_error(monkeypatch): + # A CUDA kernel launch is async: xformers_attention can return before the GPU + # reports a failure. The probe must synchronize so a deferred launch/runtime error + # is caught and disables xformers here, instead of surfacing later on an unrelated + # CUDA call (unslothai/unsloth#6828 review). No GPU needed: everything is stubbed. + _bias = type( + "B", + (), + { + "BlockDiagonalCausalMask": type( + "M", (), {"from_seqlens": staticmethod(lambda seqlens: None)} + ) + }, + ) + monkeypatch.setattr(ad, "SUPPORTS_BFLOAT16", True) + monkeypatch.setattr(ad.torch, "zeros", lambda *a, **k: object()) + monkeypatch.setattr(ad, "xformers", type("X", (), {"attn_bias": _bias})) + monkeypatch.setattr(ad, "xformers_attention", lambda *a, **k: None) # "succeeds" + + def deferred_cuda_error(): + raise RuntimeError("CUDA error: an illegal memory access was encountered") + + monkeypatch.setattr(ad.torch.cuda, "synchronize", deferred_cuda_error) + # Without the synchronize the stubbed op returns cleanly and the probe wrongly + # reports True; the sync surfaces the deferred error so the probe returns False. + assert ad._xformers_runs_on_device() is False diff --git a/unsloth/utils/attention_dispatch.py b/unsloth/utils/attention_dispatch.py index 68fb33dad9..eda6103d5b 100644 --- a/unsloth/utils/attention_dispatch.py +++ b/unsloth/utils/attention_dispatch.py @@ -35,12 +35,44 @@ if HAS_FLASH_ATTENTION: from flash_attn import flash_attn_func, flash_attn_varlen_func HAS_XFORMERS = xformers is not None -# xformers kernels (FA3, FA2, cutlass) only support compute capability <= 9.0. -# Disable xformers on newer GPUs (e.g. RTX 5070 Ti / sm_120) and fall back to SDPA. -if HAS_XFORMERS and torch.cuda.is_available(): - _cc = torch.cuda.get_device_capability() - if _cc[0] >= 12: + +def _xformers_runs_on_device() -> bool: + """One tiny attention forward; True iff the xformers kernel actually runs here.""" + try: + # Pre-Ampere GPUs (sm < 80: Turing/Volta) have no bfloat16 attention kernel + # but run xformers fine in float16, so pick the dtype the device supports. + dtype = torch.bfloat16 if SUPPORTS_BFLOAT16 else torch.float16 + q = torch.zeros((1, 8, 1, 64), device = "cuda", dtype = dtype) + attn_bias = xformers.attn_bias.BlockDiagonalCausalMask.from_seqlens([8]) + xformers_attention(q, q, q, attn_bias = attn_bias) + # Launches are async; synchronize so a deferred kernel failure fails the probe here. + torch.cuda.synchronize() + return True + except Exception: + return False + + +def _xformers_disabled_for_capability(capability, probe = _xformers_runs_on_device) -> bool: + # At sm_120 (RTX 50-series) xformers' cutlass op is capability-rejected (caps at + # sm_90) and its flash-2 op runs only if the build ships an sm_120 kernel, so run + # one real forward to decide. Below sm_120 xformers always works; skip the probe. + if capability[0] < 12: + return False + return not probe() + + +# FlashAttention always wins in select_attention_backend and nothing downgrades +# flash -> xformers, so when it's installed xformers is never selected: skip the probe. +if HAS_XFORMERS and not HAS_FLASH_ATTENTION and torch.cuda.is_available(): + if _xformers_disabled_for_capability(torch.cuda.get_device_capability()): HAS_XFORMERS = False + +# On sm_100+ (B200, sm_120) xformers' fp32-capable cutlass op is capability-rejected and +# only its fp16/bf16 flash-2 op runs, so fp32 Q/K/V (DoRA, #1013) must be downcast there; +# below sm_100 cutlass handles fp32 natively. Read once from device 0, like the probe gate. +_XFORMERS_FP32_UNSUPPORTED = ( + torch.cuda.is_available() and torch.cuda.get_device_capability()[0] >= 10 +) SDPA_HAS_GQA = "enable_gqa" in (scaled_dot_product_attention.__doc__ or "") # PrefixGrouper kernel, resolved once when the env gate is on so PG-off users never load @@ -201,9 +233,13 @@ def run_attention( requires_grad = context.requires_grad sliding_window = context.sliding_window - # DoRA promotes q/k/v_proj outputs to fp32, which FlashAttention rejects, so - # downcast any fp32 Q/K/V to a flash-supported dtype (#1013). - if backend in (FLASH_DENSE, FLASH_VARLEN) and torch.float32 in ( + # DoRA promotes q/k/v_proj outputs to fp32, which FlashAttention rejects (and so does + # the xformers flash-2 op on sm_100+, see _XFORMERS_FP32_UNSUPPORTED), so downcast any + # fp32 Q/K/V to a supported dtype (#1013). + if ( + backend in (FLASH_DENSE, FLASH_VARLEN) + or (backend == XFORMERS and _XFORMERS_FP32_UNSUPPORTED) + ) and torch.float32 in ( Q.dtype, K.dtype, V.dtype, @@ -211,14 +247,16 @@ def run_attention( # Prefer the autocast dtype, else a non-fp32 input's dtype, then clamp. if torch.is_autocast_enabled(): try: - flash_dtype = torch.get_autocast_dtype("cuda") + downcast_dtype = torch.get_autocast_dtype("cuda") except (AttributeError, TypeError): - flash_dtype = torch.get_autocast_gpu_dtype() + downcast_dtype = torch.get_autocast_gpu_dtype() else: - flash_dtype = next((d for d in (Q.dtype, K.dtype, V.dtype) if d != torch.float32), None) - if flash_dtype not in (torch.float16, torch.bfloat16): - flash_dtype = torch.bfloat16 if SUPPORTS_BFLOAT16 else torch.float16 - Q, K, V = Q.to(flash_dtype), K.to(flash_dtype), V.to(flash_dtype) + downcast_dtype = next( + (d for d in (Q.dtype, K.dtype, V.dtype) if d != torch.float32), None + ) + if downcast_dtype not in (torch.float16, torch.bfloat16): + downcast_dtype = torch.bfloat16 if SUPPORTS_BFLOAT16 else torch.float16 + Q, K, V = Q.to(downcast_dtype), K.to(downcast_dtype), V.to(downcast_dtype) if backend == FLASH_VARLEN: Q_f = Q.transpose(1, 2).reshape(bsz * q_len, n_heads, head_dim) From fea7d9ba345262d42d2ea125bea330d65350cbe0 Mon Sep 17 00:00:00 2001 From: Nilay <118994073+NilayYadav@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:00:21 +0530 Subject: [PATCH 010/329] Studio: render image content returned by MCP tools (#7081) * MCP image handling * clean upg * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: return MCP error results so image content is not dropped FastMCP client.call_tool raises ToolError by default on an is_error result, so it never reaches _flatten_result and any returned image is dropped. Pass raise_on_error=False so error results flow through _flatten_result and keep their images. Transport failures still raise and hit the existing handler. Add a regression test for the real path. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: accept raise_on_error kwarg in MCP test fake clients The call_tool_sync fix passes raise_on_error=False to client.call_tool. Update the fake MCP clients patched into mcp_client._client so their call_tool signatures accept the keyword, keeping the stdio/servers MCP test suites green. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten MCP raise_on_error rationale comments * Studio: only strip MCP image sentinel when suffix is a valid image envelope * Studio: validate MCP image envelope in chat adapter and keep base64 out of exports * Studio: sanitize MCP images in all export formats and fall through to sandbox parser on invalid marker --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen --- studio/backend/core/inference/mcp_client.py | 35 +++- .../core/inference/tool_loop_controller.py | 24 +++ .../backend/tests/test_mcp_flatten_result.py | 177 ++++++++++++++++++ studio/backend/tests/test_mcp_servers.py | 14 +- studio/backend/tests/test_mcp_stdio_pr5863.py | 7 +- .../components/assistant-ui/tool-fallback.tsx | 53 +++++- .../src/features/chat/api/chat-adapter.ts | 52 +++++ .../chat/hooks/use-chat-search-index.ts | 34 +++- .../prompt-storage/prompt-storage-dialog.tsx | 16 +- 9 files changed, 400 insertions(+), 12 deletions(-) create mode 100644 studio/backend/tests/test_mcp_flatten_result.py diff --git a/studio/backend/core/inference/mcp_client.py b/studio/backend/core/inference/mcp_client.py index 7bd4a7d6e9..c6b8acfdc4 100644 --- a/studio/backend/core/inference/mcp_client.py +++ b/studio/backend/core/inference/mcp_client.py @@ -298,20 +298,48 @@ def invalidate_tool_cache(server_id: Optional[str] = None) -> None: _probe_cooloff_until.pop(server_id, None) +MCP_IMAGES_SENTINEL = "__MCP_IMAGES__:" +MAX_IMAGE_PAYLOAD_CHARS = 12_000_000 + + def _flatten_result(result: Any) -> str: parts = [] + images = [] + omitted = 0 + budget = MAX_IMAGE_PAYLOAD_CHARS for block in getattr(result, "content", None) or []: text = getattr(block, "text", None) if text: parts.append(str(text)) + continue + data = getattr(block, "data", None) + mime = getattr(block, "mimeType", None) + if data and isinstance(mime, str) and mime.startswith("image/"): + data = str(data) + if len(data) > budget: + omitted += 1 + continue + budget -= len(data) + images.append({"data": data, "mimeType": mime}) body = "\n".join(parts) if not body: structured = getattr(result, "structured_content", None) body = str(structured) if structured is not None else "" + if images or omitted: + notes = [] + if images: + n = len(images) + notes.append(f"{n} image{'s' if n > 1 else ''} attached; displayed to the user") + if omitted: + notes.append(f"{omitted} image{'s' if omitted > 1 else ''} omitted (too large)") + note = f"[{'; '.join(notes)}]" + body = f"{body}\n{note}" if body else note if getattr(result, "is_error", False): # "Error: " prefix triggers tool_call_parser's TOOL_ERROR_PREFIXES nudge. - return f"Error: {body}" if body else "Error: tool returned no content" + body = f"Error: {body}" if body else "Error: tool returned no content" + if images: + body += "\n" + MCP_IMAGES_SENTINEL + json.dumps(images) return body @@ -333,7 +361,10 @@ def call_tool_sync( async def _call() -> Any: async with _client(url, headers, use_oauth) as client: - return await client.call_tool(name, args) + # raise_on_error=False lets an is_error result (which may still carry + # image content) reach _flatten_result instead of FastMCP raising ToolError + # and dropping the images. Transport failures still raise (handled below). + return await client.call_tool(name, args, raise_on_error = False) async def _watch_cancel() -> None: # 50 ms cadence keeps cancellation responsive without busy-looping; diff --git a/studio/backend/core/inference/tool_loop_controller.py b/studio/backend/core/inference/tool_loop_controller.py index cb751ede3d..f595531b90 100644 --- a/studio/backend/core/inference/tool_loop_controller.py +++ b/studio/backend/core/inference/tool_loop_controller.py @@ -233,9 +233,33 @@ def is_tool_error(result: str) -> bool: return isinstance(result, str) and result.lstrip().startswith(TOOL_ERROR_PREFIXES) +def _strip_mcp_image_suffix(result: str) -> str: + """Drop a trailing __MCP_IMAGES__ envelope only when it is the valid JSON + image array appended by _flatten_result, so legit tool text that merely + mentions the marker is not truncated.""" + head, sep, payload = result.rpartition("\n__MCP_IMAGES__:") + if not sep: + return result + try: + images = json.loads(payload) + except (ValueError, RecursionError): + return result + if not isinstance(images, list) or not images: + return result + if not all( + isinstance(img, dict) + and isinstance(img.get("data"), str) + and isinstance(img.get("mimeType"), str) + for img in images + ): + return result + return head.rstrip() + + def strip_result_for_model(result: str) -> str: """Remove frontend-only sentinels (image paths, RAG source map) before feeding the result back to the model.""" + result = _strip_mcp_image_suffix(result) for sentinel in ("__IMAGES__:", "__RAG_SOURCES__:"): if sentinel in result: result = result.split(sentinel, 1)[0].rstrip() diff --git a/studio/backend/tests/test_mcp_flatten_result.py b/studio/backend/tests/test_mcp_flatten_result.py new file mode 100644 index 0000000000..7daee799f9 --- /dev/null +++ b/studio/backend/tests/test_mcp_flatten_result.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +import contextlib +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +from core.inference import mcp_client +from core.inference.mcp_client import ( + MAX_IMAGE_PAYLOAD_CHARS, + MCP_IMAGES_SENTINEL, + _flatten_result, + call_tool_sync, +) +from core.inference.tool_loop_controller import is_tool_error, strip_result_for_model + +PNG_B64 = "iVBORw0KGgoAAAANSUhEUg==" + + +def _text(value: str) -> SimpleNamespace: + return SimpleNamespace(type = "text", text = value) + + +def _image(data: str = PNG_B64, mime: str = "image/png") -> SimpleNamespace: + return SimpleNamespace(type = "image", data = data, mimeType = mime) + + +def _result( + *blocks, + is_error = False, + structured = None, +) -> SimpleNamespace: + return SimpleNamespace( + content = list(blocks), + is_error = is_error, + structured_content = structured, + ) + + +def test_text_only_result_unchanged(): + assert _flatten_result(_result(_text("hello"))) == "hello" + + +def test_image_only_result_keeps_image_and_notes_model(): + flat = _flatten_result(_result(_image())) + body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1) + assert body == "[1 image attached; displayed to the user]" + assert json.loads(payload) == [{"data": PNG_B64, "mimeType": "image/png"}] + + +def test_text_plus_image_keeps_both(): + flat = _flatten_result(_result(_text("Took a screenshot"), _image())) + body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1) + assert body == "Took a screenshot\n[1 image attached; displayed to the user]" + assert json.loads(payload)[0]["mimeType"] == "image/png" + + +def test_multiple_images_pluralized(): + flat = _flatten_result(_result(_image(), _image(mime = "image/jpeg"))) + body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1) + assert "[2 images attached; displayed to the user]" in body + assert [img["mimeType"] for img in json.loads(payload)] == ["image/png", "image/jpeg"] + + +def test_strip_result_for_model_drops_image_payload(): + flat = _flatten_result(_result(_text("Took a screenshot"), _image())) + stripped = strip_result_for_model(flat) + assert stripped == "Took a screenshot\n[1 image attached; displayed to the user]" + assert PNG_B64 not in stripped + + +def test_strip_preserves_literal_mcp_sentinel_in_text(): + # A tool that legitimately returns text containing the marker (e.g. reading + # source/docs that quote it) must not be truncated: the suffix is not a + # valid JSON image array. + text = "before\n__MCP_IMAGES__: literal from source\nafter" + assert strip_result_for_model(text) == text + + +def test_strip_preserves_non_image_json_after_marker(): + text = 'log line\n__MCP_IMAGES__:["not", "image", "dicts"]' + assert strip_result_for_model(text) == text + + +def test_strip_removes_only_valid_terminal_envelope(): + text = ( + "Earlier mention: __MCP_IMAGES__: is documented here" + "\n[1 image attached; displayed to the user]" + '\n__MCP_IMAGES__:[{"data": "AAAA", "mimeType": "image/png"}]' + ) + assert strip_result_for_model(text) == ( + "Earlier mention: __MCP_IMAGES__: is documented here" + "\n[1 image attached; displayed to the user]" + ) + + +def test_strip_still_handles_images_and_rag_sentinels(): + assert strip_result_for_model("output\n__IMAGES__:['a.png']") == "output" + assert strip_result_for_model("answer\n__RAG_SOURCES__:[{}]") == "answer" + + +def test_error_result_keeps_error_prefix_and_images(): + flat = _flatten_result(_result(_text("boom"), _image(), is_error = True)) + assert flat.startswith("Error: boom") + assert is_tool_error(flat) + assert MCP_IMAGES_SENTINEL in flat + + +def test_image_only_error_no_longer_reports_no_content(): + flat = _flatten_result(_result(_image(), is_error = True)) + assert flat.startswith("Error: [1 image attached") + assert "tool returned no content" not in flat + + +def test_oversized_image_omitted_with_note(): + huge = "A" * (MAX_IMAGE_PAYLOAD_CHARS + 1) + flat = _flatten_result(_result(_image(data = huge))) + assert flat == "[1 image omitted (too large)]" + assert MCP_IMAGES_SENTINEL not in flat + + +def test_oversized_budget_shared_across_images(): + big = "A" * (MAX_IMAGE_PAYLOAD_CHARS - 10) + flat = _flatten_result(_result(_image(data = big), _image())) + body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1) + assert "1 image attached" in body + assert "1 image omitted (too large)" in body + images = json.loads(payload) + assert len(images) == 1 and images[0]["data"] == big + + +def test_non_image_binary_block_still_ignored(): + flat = _flatten_result( + _result(SimpleNamespace(type = "audio", data = PNG_B64, mimeType = "audio/wav")) + ) + assert flat == "" + + +def test_structured_content_fallback_still_used(): + flat = _flatten_result(_result(structured = {"ok": True})) + assert flat == "{'ok': True}" + + +def test_call_tool_sync_passes_raise_on_error_false_and_keeps_error_images(monkeypatch): + # Guards that call_tool_sync passes raise_on_error=False, so an is_error result + # with image content reaches _flatten_result instead of FastMCP raising ToolError. + seen = {} + + class _FakeClient: + async def call_tool( + self, + name, + args, + raise_on_error = True, + ): + seen["raise_on_error"] = raise_on_error + return _result(_text("boom"), _image(), is_error = True) + + @contextlib.asynccontextmanager + async def _fake_client(url, headers, use_oauth): + yield _FakeClient() + + monkeypatch.setattr(mcp_client, "_client", _fake_client) + out = call_tool_sync("http://x", None, "take_screenshot", {}) + + assert seen["raise_on_error"] is False + assert out.startswith("Error: boom") + assert MCP_IMAGES_SENTINEL in out + assert is_tool_error(out) diff --git a/studio/backend/tests/test_mcp_servers.py b/studio/backend/tests/test_mcp_servers.py index 6d26d075cf..24784ca34c 100644 --- a/studio/backend/tests/test_mcp_servers.py +++ b/studio/backend/tests/test_mcp_servers.py @@ -198,7 +198,12 @@ def test_call_tool_sync_respects_pre_set_cancel_event(monkeypatch): async def __aexit__(self, *args): return False - async def call_tool(self, name, args): + async def call_tool( + self, + name, + args, + raise_on_error = True, + ): import asyncio as _asyncio await _asyncio.sleep(30) # never finishes during the test @@ -520,7 +525,12 @@ def test_call_tool_sync_short_circuits_on_pre_set_cancel(monkeypatch): async def __aexit__(self, *args): return False - async def call_tool(self, name, args): + async def call_tool( + self, + name, + args, + raise_on_error = True, + ): return "ran" monkeypatch.setattr(mcp_client, "_client", lambda *a, **kw: _StubClient()) diff --git a/studio/backend/tests/test_mcp_stdio_pr5863.py b/studio/backend/tests/test_mcp_stdio_pr5863.py index 15fe553fb2..1cb1211cf2 100644 --- a/studio/backend/tests/test_mcp_stdio_pr5863.py +++ b/studio/backend/tests/test_mcp_stdio_pr5863.py @@ -93,7 +93,12 @@ class _RecordingClient: async def list_tools(self): return [_FakeTool("list_directory"), _FakeTool("write_file")] - async def call_tool(self, name, args): + async def call_tool( + self, + name, + args, + raise_on_error = True, + ): return _FakeResult(f"called {name}") diff --git a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx index 20a22c3a4a..d0bc12706e 100644 --- a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx @@ -260,6 +260,30 @@ function ToolFallbackArgs({ ); } +interface McpImageResult { + text: string; + images: { data: string; mimeType: string }[]; +} + +function isMcpImageResult(val: unknown): val is McpImageResult { + if (typeof val !== "object" || val === null) { + return false; + } + const v = val as { text?: unknown; images?: unknown }; + return ( + typeof v.text === "string" && + Array.isArray(v.images) && + v.images.length > 0 && + v.images.every( + (img: unknown) => + typeof img === "object" && + img !== null && + typeof (img as { data?: unknown }).data === "string" && + typeof (img as { mimeType?: unknown }).mimeType === "string", + ) + ); +} + function ToolFallbackResult({ result, className, @@ -271,6 +295,8 @@ function ToolFallbackResult({ return null; } + const imageResult = isMcpImageResult(result) ? result : null; + return (

Result:

-
-        {typeof result === "string" ? result : JSON.stringify(result, null, 2)}
-      
+ {imageResult ? ( + <> + {imageResult.text && ( +
+              {imageResult.text}
+            
+ )} +
+ {imageResult.images.map((img, i) => ( + {`Tool + ))} +
+ + ) : ( +
+          {typeof result === "string" ? result : JSON.stringify(result, null, 2)}
+        
+ )}
); } diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index ca062bf93c..4fb7d9bd7e 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -901,6 +901,33 @@ function serializeAssistantToolCallPart( return entry; } +export interface McpImageToolResult { + text: string; + images: { data: string; mimeType: string }[]; +} + +export function isMcpImageToolResult( + val: unknown, +): val is McpImageToolResult { + if (typeof val !== "object" || val === null) { + return false; + } + const v = val as { text?: unknown; images?: unknown; sessionId?: unknown }; + return ( + typeof v.text === "string" && + v.sessionId === undefined && + Array.isArray(v.images) && + v.images.length > 0 && + v.images.every( + (img: unknown) => + typeof img === "object" && + img !== null && + typeof (img as { data?: unknown }).data === "string" && + typeof (img as { mimeType?: unknown }).mimeType === "string", + ) + ); +} + function serializeToolResultPart( part: ToolCallMessagePart, ): SerializedToolResult | null { @@ -920,6 +947,8 @@ function serializeToolResultPart( // content; serialise a sentinel JSON so legitimately empty tool // outputs still round-trip the follow-up turn to the provider. content = result.length > 0 ? result : JSON.stringify({ result: "" }); + } else if (isMcpImageToolResult(result)) { + content = result.text.length > 0 ? result.text : JSON.stringify({ result: "" }); } else { try { content = JSON.stringify(result); @@ -3196,9 +3225,12 @@ export function createOpenAIStreamAdapter( const rawResult = (toolEvent.result as string) ?? ""; const imgMarker = "\n__IMAGES__:"; const imgIdx = rawResult.lastIndexOf(imgMarker); + const mcpImgMarker = "\n__MCP_IMAGES__:"; + const mcpImgIdx = rawResult.lastIndexOf(mcpImgMarker); let parsedResult: | string | { text: string; images: string[]; sessionId: string } + | McpImageToolResult | { image_b64: string; image_mime: string; @@ -3208,6 +3240,24 @@ export function createOpenAIStreamAdapter( prompt?: string; }; const imageB64 = toolEvent.image_b64 as string | undefined; + // A valid MCP image envelope wins; an invalid marker falls + // through so a sandbox __IMAGES__ suffix still renders and + // legit text round-trips unchanged. + let mcpImages: McpImageToolResult | null = null; + if (mcpImgIdx !== -1) { + try { + const images = JSON.parse( + rawResult.slice(mcpImgIdx + mcpImgMarker.length), + ); + const candidate = { + text: rawResult.slice(0, mcpImgIdx), + images, + }; + if (isMcpImageToolResult(candidate)) mcpImages = candidate; + } catch { + // Not a valid envelope; fall through below. + } + } if ( toolCallParts[idx].toolName === "image_generation" && typeof imageB64 === "string" && @@ -3225,6 +3275,8 @@ export function createOpenAIStreamAdapter( background: toolEvent.background as string | undefined, prompt: toolEvent.prompt as string | undefined, }; + } else if (mcpImages !== null) { + parsedResult = mcpImages; } else if (imgIdx !== -1) { const text = rawResult.slice(0, imgIdx); // Fall back to "_default" to match the backend sandbox diff --git a/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts b/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts index 841f2c2a2f..9badd43bc3 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts @@ -28,13 +28,43 @@ const SEARCH_REBUILD_DEBOUNCE_MS = 300; // Keys whose values are base64 image/audio payloads, not searchable text. const BINARY_KEY = /b64|base64|^(images?|audio|video)$/i; +// Drop a trailing __MCP_IMAGES__ envelope only when it is the valid JSON image +// array appended by the backend, so legit tool text that merely mentions the +// marker stays searchable. (base64 runs below are scrubbed regardless.) +function stripMcpImageSuffix(value: string): string { + const marker = "\n__MCP_IMAGES__:"; + const idx = value.lastIndexOf(marker); + if (idx === -1) return value; + try { + const images: unknown = JSON.parse(value.slice(idx + marker.length)); + if ( + Array.isArray(images) && + images.length > 0 && + images.every( + (img) => + typeof img === "object" && + img !== null && + typeof (img as Record).data === "string" && + typeof (img as Record).mimeType === "string", + ) + ) { + return value.slice(0, idx); + } + } catch { + // Not a valid envelope; leave the text intact. + } + return value; +} + // Readable text from tool args/results, dropping base64 image/audio blobs so // they never bloat the index (object fields by key, plus data URLs / long // base64 runs and the "__IMAGES__" suffix inside strings). function searchableText(value: unknown, depth = 0): string { if (typeof value === "string") { - const cut = value.indexOf("\n__IMAGES__:"); - return (cut === -1 ? value : value.slice(0, cut)) + let text = stripMcpImageSuffix(value); + const cut = text.indexOf("\n__IMAGES__:"); + if (cut !== -1) text = text.slice(0, cut); + return text .replace(/data:[^;,\s]+;base64,[A-Za-z0-9+/=]+/g, " ") .replace(/[A-Za-z0-9+/]{120,}={0,2}/g, " "); } diff --git a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx index ee3a49526f..f4546a01a8 100644 --- a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx +++ b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx @@ -54,6 +54,7 @@ import { syncStoredChatMessages, } from "../utils/chat-history-storage"; import { notifyChatHistoryUpdated } from "../api/chat-api"; +import { isMcpImageToolResult } from "../api/chat-adapter"; import { usePlusMenuPrefsStore } from "../stores/plus-menu-prefs-store"; import type { ThreadRecord, MessageRecord } from "../types"; @@ -170,11 +171,14 @@ function contentBlocksToText(content: unknown): string { parts.push("[thinking]\n" + thinkText + "\n[/thinking]"); } } else if (p.type === "tool-call") { + // Keep base64 image payloads out of every export format: use the + // model-visible text for MCP image results (matches chat replay). + const result = isMcpImageToolResult(p.result) ? p.result.text : p.result; parts.push( JSON.stringify({ tool_call: p.toolName, args: p.args, - result: p.result, + result, }), ); } else if (p.type === "image") { @@ -299,7 +303,15 @@ function messageToOpenAI(msg: { role: unknown; content: unknown; attachments?: u const argsStr = p.args != null ? JSON.stringify(p.args) : (typeof p.argsText === "string" ? p.argsText : "{}"); toolCalls.push({ id, type: "function", function: { name, arguments: argsStr } }); if (p.result !== undefined && p.result !== null) { - const resultStr = typeof p.result === "string" ? p.result : JSON.stringify(p.result); + // Keep base64 image payloads out of exports: MCP image results carry + // their model-visible text alongside the data, so serialize the text + // (matching chat replay) instead of the full object. + const resultStr = + typeof p.result === "string" + ? p.result + : isMcpImageToolResult(p.result) + ? p.result.text + : JSON.stringify(p.result); toolResults.push({ role: "tool", tool_call_id: id, name, content: resultStr }); } } From 6e375a5b177cd39dfcbe6f1e01b287c5c0b83635 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:38:04 -0700 Subject: [PATCH 011/329] Studio: add French, German, Spanish, Hindi, Arabic, Russian and Korean display languages (#7076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Studio: add 7 display languages, complete and fix existing locales Adds fully translated French, German, Spanish, Hindi, Arabic, Russian and Korean locales. Fills in all missing keys for zh-CN (113), ja (71) and pt-BR (47), fixes translation errors found in review, and reorders the language dropdown by popularity. All overlays pass check-parity with zero missing keys and zero placeholder mismatches. * Studio: default display language to auto detect The language preference now defaults to auto and resolves against the browser language list, with exact tag match first and language subtag match second (pt-PT resolves to pt-BR, zh-TW to zh-CN). Auto detect is the first dropdown option and is translated in every locale. Explicit choices still persist and sync; personalization sync now round trips the preference instead of the resolved locale so auto stays auto across devices. Auto mode also follows browser languagechange events. * Studio: guard import.meta.env in translate for non-Vite contexts translate() read import.meta.env.DEV directly, which throws when the module runs outside Vite (SSR or Node tooling). Optional-chain it so the dev-only warning is skipped and translation still works everywhere. * Studio: RTL for Arabic, translate recipes, keep Traditional Chinese off zh-CN - Sync document dir from a per-locale dir field so Arabic mirrors the layout instead of rendering RTL text in an LTR shell. - Translate the recipes nav label in fr, de, ko and hi to match the other locales (Recettes, Rezepte, and native forms). - Detection no longer maps Traditional Chinese (zh-Hant / zh-TW / zh-HK / zh-MO) to Simplified zh-CN; those tags fall through to the next preferred language. Simplified tags (zh, zh-CN, zh-SG, zh-Hans) still resolve to zh-CN. * Studio: don't treat legacy synced English as an explicit language pick The old sync serialized the resolved locale on every save, so existing profiles carry appearance.language 'en' even when the user never chose a language. Hydrating that as a pinned locale forced non-English browsers back to English under the new Auto detect default. Payloads now carry version 2 (the preference itself); on hydrate a version 1 'en' maps to auto, while explicit picks and all version 2 values are kept as-is. * Studio: persist only known language codes from the locale table normalizePreference now returns a value re-derived from the LOCALES keys instead of the raw input. It stays functionally identical (the stored value was already whitelisted) but makes it explicit that only known, non-sensitive language codes are written to localStorage, and clears a false-positive clear-text-storage scan on the persistence path. * Studio i18n: fix Train label transliteration and tidy locale consistency - ja and hi: the nav and route Train label used the railway transliteration (トレイン and ट्रेन); switch to the training term already used everywhere else in each file (トレーニング, ट्रेनिंग). - zh-CN: keep VRAM in English to match every other locale and the PR's own keep-English rule, and drop an extra clause added to the upload size hint so it matches the English source. - hi: translate Recents to हाल के in the export and import section to match the sidebar label, and point users to the Configure tab by its translated name (कॉन्फ़िगर). - ru: reword the preview sharing hint to avoid the "disable to disable" repetition. i18n parity and the type checked build stay green. * Studio i18n: keep Arabic layout LTR until physical-direction CSS is converted Setting ar to dir rtl only mirrors the flex based shell, sidebar and settings dialog. The shared select, dialog and dropdown primitives use physical-direction utilities (right-2, top-5 right-5, ml-auto) that do not flip under dir rtl, so chevrons, close buttons and check marks land on the wrong side. Keep Arabic on an LTR layout for now, matching the original plan in this PR. Arabic text still renders right to left per element via bidi and chat content keeps dir auto, so nothing regresses. Full layout mirroring can follow once the physical-direction classes are converted to logical ones. * Studio i18n: do not let a generic zh after a Traditional tag pick Simplified navigator.languages can be a list like ['zh-TW', 'zh', 'en-US']. The zh-TW pass already falls through, but the bare zh then reached the language-subtag match and selected zh-CN, so Traditional Chinese users still got Simplified and the guard was defeated. detectLocale now remembers when a Traditional tag was seen and skips a later bare zh, so detection keeps falling through to the next non-Chinese language. A lone bare zh, and explicit zh-CN or zh-Hans fallbacks, still resolve to Simplified as before. * Studio i18n: collapse two locale comments to a single line The Arabic dir note in messages.ts and the bare-zh note in locale-store.ts were two lines each; tighten each to one. Comment only, no behavior change. * Studio i18n: translate Hindi strings that were left in English Seventeen hi.ts labels stayed in English while all the other locales translated them: the training parameter labels (Grad Accum, Grad Norm, Grad Checkpoint, Eval Loss, Clip p95/p99, Seed, Continued Pretraining), the API example labels (curl/Python/JavaScript + tools/advanced), Hugging Face token, the VRAM estimate and the training terminal start line. Parity only checks key/placeholder presence so it did not catch these. Brand and technical tokens (curl, Python, VRAM, Loss, p95/p99, Hugging Face, unsloth) stay in English as elsewhere. --------- Co-authored-by: danielhanchen --- .../profile/hooks/use-personalization-sync.ts | 48 +- .../settings/components/language-select.tsx | 14 +- studio/frontend/src/i18n/check-parity.ts | 16 +- studio/frontend/src/i18n/index.ts | 6 + studio/frontend/src/i18n/locale-store.ts | 169 ++- studio/frontend/src/i18n/locales/ar.ts | 1001 ++++++++++++++++ studio/frontend/src/i18n/locales/de.ts | 1042 ++++++++++++++++ studio/frontend/src/i18n/locales/en.ts | 1 + studio/frontend/src/i18n/locales/es.ts | 1043 +++++++++++++++++ studio/frontend/src/i18n/locales/fr.ts | 1038 ++++++++++++++++ studio/frontend/src/i18n/locales/hi.ts | 999 ++++++++++++++++ studio/frontend/src/i18n/locales/ja.ts | 101 +- studio/frontend/src/i18n/locales/ko.ts | 1002 ++++++++++++++++ studio/frontend/src/i18n/locales/pt-br.ts | 76 +- studio/frontend/src/i18n/locales/ru.ts | 999 ++++++++++++++++ studio/frontend/src/i18n/locales/zh-CN.ts | 171 ++- studio/frontend/src/i18n/messages.ts | 39 +- 17 files changed, 7684 insertions(+), 81 deletions(-) create mode 100644 studio/frontend/src/i18n/locales/ar.ts create mode 100644 studio/frontend/src/i18n/locales/de.ts create mode 100644 studio/frontend/src/i18n/locales/es.ts create mode 100644 studio/frontend/src/i18n/locales/fr.ts create mode 100644 studio/frontend/src/i18n/locales/hi.ts create mode 100644 studio/frontend/src/i18n/locales/ko.ts create mode 100644 studio/frontend/src/i18n/locales/ru.ts diff --git a/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts b/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts index eac1a64d7a..5dbb7b54bf 100644 --- a/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts +++ b/studio/frontend/src/features/profile/hooks/use-personalization-sync.ts @@ -9,12 +9,12 @@ import { type Theme, } from "@/features/settings"; import { - DEFAULT_LOCALE, - getLocale, - isSupportedLocale, + DEFAULT_LOCALE_PREFERENCE, + getLocalePreference, + isLocalePreference, setLocale, - useLocale, - type Locale, + useLocalePreference, + type LocalePreference, } from "@/i18n"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -25,6 +25,11 @@ import type { AvatarShape } from "../stores/user-profile-store"; const PUSH_DEBOUNCE_MS = 800; +// Version 2 payloads store the language preference ("auto" or a pinned +// locale). Version 1 always serialized the resolved locale, so its "en" is +// usually the old default rather than an explicit pick. +const PERSONALIZATION_VERSION = 2; + type ProfileSnapshot = { displayName: string; nickname: string; @@ -110,10 +115,10 @@ function profileSnapshot(): ProfileSnapshot { function payload( profile: ProfileSnapshot, theme: Theme, - language: Locale | null, + language: LocalePreference | null, ): PersonalizationWrite { return { - version: 1, + version: PERSONALIZATION_VERSION, profile: normalizeProfile(profile), appearance: { theme, language }, }; @@ -123,10 +128,23 @@ function serialized(data: PersonalizationWrite): string { return JSON.stringify(data); } +// Version 1 clients wrote language on every save, so a legacy "en" usually +// means the user never picked a language. Map it to auto; explicit picks of +// other locales (the old default was English) are kept. Version 2 payloads +// are trusted verbatim, so a deliberate English pick stays pinned. +export function remoteLanguagePreference( + version: unknown, + language: unknown, +): unknown { + const isLegacy = typeof version !== "number" || version < 2; + if (isLegacy && language === "en") return DEFAULT_LOCALE_PREFERENCE; + return language; +} + function hasLocalSettings( profile: ProfileSnapshot, theme: Theme, - language: Locale, + language: LocalePreference, ): boolean { return Boolean( profile.displayName || @@ -134,7 +152,7 @@ function hasLocalSettings( profile.avatarDataUrl || profile.avatarShape !== "circle" || theme !== "system" || - language !== DEFAULT_LOCALE, + language !== DEFAULT_LOCALE_PREFERENCE, ); } @@ -144,7 +162,7 @@ export function usePersonalizationSync(enabled: boolean): void { const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl); const avatarShape = useUserProfileStore((s) => s.avatarShape); const { theme } = useTheme(); - const language = useLocale(); + const language = useLocalePreference(); const [hydratedGeneration, setHydratedGeneration] = useState(0); const authGenerationRef = useRef(0); const latestThemeRef = useRef(theme); @@ -191,8 +209,12 @@ export function usePersonalizationSync(enabled: boolean): void { avatarShape: remote.profile.avatarShape === "rounded" ? "rounded" : "circle", }; const nextTheme = remote.appearance.theme; - const nextLanguage = isSupportedLocale(remote.appearance.language) - ? remote.appearance.language + const remoteLanguage = remoteLanguagePreference( + remote.version, + remote.appearance.language, + ); + const nextLanguage = isLocalePreference(remoteLanguage) + ? remoteLanguage : latestLanguageRef.current; useUserProfileStore.setState(nextProfile); if (nextTheme !== latestThemeRef.current) setTheme(nextTheme); @@ -207,7 +229,7 @@ export function usePersonalizationSync(enabled: boolean): void { useUserProfileStore.setState(nextProfile); } const nextTheme = latestThemeRef.current; - const nextLanguage = getLocale(); + const nextLanguage = getLocalePreference(); const nextPayload = payload(nextProfile, nextTheme, nextLanguage); const nextSerialized = serialized(nextPayload); if (hasLocalSettings(nextProfile, nextTheme, nextLanguage)) { diff --git a/studio/frontend/src/features/settings/components/language-select.tsx b/studio/frontend/src/features/settings/components/language-select.tsx index 9d30e06147..01fe049a56 100644 --- a/studio/frontend/src/features/settings/components/language-select.tsx +++ b/studio/frontend/src/features/settings/components/language-select.tsx @@ -9,22 +9,23 @@ import { SelectValue, } from "@/components/ui/select"; import { + AUTO_LOCALE, LOCALES, - isSupportedLocale, + isLocalePreference, setLocale, useT, - useLocale, + useLocalePreference, } from "@/i18n"; export function LanguageSelect() { const t = useT(); - const locale = useLocale(); + const preference = useLocalePreference(); return ( `: the closer need not match the opener.""" + text = '## 1.0\n\n\n' + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "9.9.9"] + + +@pytest.mark.parametrize("tag", ["details", "div", "table"]) +def test_type_6_blocks_run_until_a_blank_line(changelog_module, tag): + """`
` holds Markdown only after a blank line closes the block, so + a heading pressed against the opening tag is not a release.""" + packed = f"## 1.0\n\n<{tag}>\n## 9.9.9\n\n\n- note\n" + assert [e.version for e in changelog_module.parse_changelog(packed)] == ["1.0"] + spaced = f"## 1.0\n\n<{tag}>\n\n## 2.0\n\n- note\n" + assert [e.version for e in changelog_module.parse_changelog(spaced)] == ["1.0", "2.0"] + + +def test_a_tag_only_line_cannot_interrupt_a_paragraph(changelog_module): + """Type 7 blocks do not interrupt a paragraph, so prose followed by a bare + tag keeps the releases below it reachable.""" + text = "## 2.0\n\nSome prose.\n\n\n## 1.0\n\n- older\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + + +def test_preview_joins_an_indented_continuation_line(): + """Four spaces only start code outside a paragraph. Inside one the line is + a wrapped continuation, so it must not be dropped from the preview.""" + src = PREVIEW.read_text(encoding = "utf-8") + # Measured from the line's container, so an item's own indent does not count. + assert "!insideBlock && line.indent - line.column >= INDENTED_CODE_INDENT" in src + # A fence indented into a list item is a block, not a wrapped line. + assert "opensDeepFence" in src + + +def test_every_packaging_path_snapshots_the_changelog(): + """`python -m build` and `pip install .` must ship the offline copy too, + so the snapshot is made by the build backend rather than by build.sh.""" + pyproject = (REPO / "pyproject.toml").read_text(encoding = "utf-8") + assert 'build_py = "_changelog_build.build_py"' in pyproject + hook = (REPO / "_changelog_build.py").read_text(encoding = "utf-8") + assert "studio" in hook and "CHANGELOG.md" in hook + # The hook has to reach the sdist, or building from one loses the snapshot. + manifest = (REPO / "MANIFEST.in").read_text(encoding = "utf-8") + assert "include _changelog_build.py" in manifest + assert "include CHANGELOG.md" in manifest + + +def test_preview_code_spans_need_a_matching_closer(): + """A closer is a run of the same length, so ``Use `` `x` `` `` keeps the + inner backticks the expanded notes show.""" + src = CODE_SPANS.read_text(encoding = "utf-8") + assert "candidate === ticks" in src, "a closer is a run of the same length" + assert "stripPadding" in src, "one space of padding is dropped, as in Markdown" + + +def test_preview_skips_thematic_breaks(): + """`- - -` renders as a rule, so it must not take a preview slot.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "THEMATIC_BREAK" in src + assert "THEMATIC_BREAK.test(visible)" in src + + +def test_preview_keeps_quoted_examples_out_of_the_headlines(): + """A quoted list is example output, not a change, so it never competes + with the release's own bullets.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "quoted: boolean" in src + assert "if (!line.quoted)" in src, "quoted bullets never become headlines" + + +def test_notes_panel_keeps_the_link_when_the_lookup_fails(): + """Retry is not the only route: the changelog page can be reachable even + when the backend lookup is not.""" + src = PANEL.read_text(encoding = "utf-8") + error_branch = src[src.index('if (state === "error")') :] + retry = error_branch.index("update-release-notes-retry") + assert error_branch.index("{link}") > retry, "link sits beside retry" + + +def test_hook_waits_for_the_desktop_auth_token(): + """The desktop popup can render before auto-auth installs its token, so a + missing token must not be recorded as a failed lookup.""" + src = NOTES_HOOK.read_text(encoding = "utf-8") + assert "hasAuthToken()" in src and "AUTH_POLL_LIMIT" in src + + +def test_installed_layout_prefers_the_bundled_changelog(tmp_path): + """Installed, the levels above studio/ are site-packages. A stray + CHANGELOG.md left there by another package must not outrank the bundled + snapshot, so those levels are only searched in a source checkout.""" + site_packages = tmp_path / "site-packages" + package = site_packages / "studio/backend/utils" + package.mkdir(parents = True) + for name in ("changelog.py", "update_status.py"): + shutil.copy(BACKEND / "utils" / name, package / name) + for parent in (site_packages / "studio", package.parent, package): + (parent / "__init__.py").write_text("", encoding = "utf-8") + (site_packages / CHANGELOG.name).write_text("## 2.0\n\n- stray\n", encoding = "utf-8") + bundled = site_packages / "studio" / CHANGELOG.name + bundled.write_text("## 2.0\n\n- bundled\n", encoding = "utf-8") + + env = {**os.environ, "PYTHONPATH": str(site_packages)} + env.pop("UNSLOTH_CHANGELOG_PATH", None) + + def served() -> str: + # cwd is outside the checkout, so this imports the installed copy. + return subprocess.run( + [ + sys.executable, + "-c", + "from studio.backend.utils import changelog\n" + "print(changelog._read_local_changelog().text)", + ], + capture_output = True, + text = True, + env = env, + cwd = tmp_path, + check = True, + ).stdout + + assert "bundled" in served() and "stray" not in served() + + # A checkout marker there means it really is a repo root, so it wins again. + (site_packages / "pyproject.toml").write_text("", encoding = "utf-8") + assert "stray" in served() + + +def test_a_section_staged_as_a_comment_reads_as_unpublished( + changelog_module, tmp_path, monkeypatch +): + """Notes staged inside render as nothing, so the popup must say + no notes were published rather than show an empty surface.""" + monkeypatch.setenv(changelog_module.DISABLE_ENV_VAR, "1") + local = tmp_path / "CHANGELOG.md" + local.write_text("## 2.0\n\n\n\n## 1.0\n\n- shipped\n", encoding = "utf-8") + monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(local)) + changelog_module.reset_changelog_cache() + try: + staged = changelog_module.get_release_notes("2.0") + assert staged["matched"] is False and staged["markdown"] is None + assert changelog_module.get_release_notes("1.0")["matched"] is True + finally: + changelog_module.reset_changelog_cache() + + +@pytest.mark.parametrize( + "body,visible", + [ + ("- note", True), + ("", False), + ("```\n```", True), + ("
\n
", True), + (" ", False), + ], +) +def test_visibility_check_only_hides_comments(changelog_module, body, visible): + assert changelog_module._renders_visibly(body) is visible + + +@pytest.mark.parametrize( + "block", + [ + "", + "", + "", + ], +) +def test_processing_instructions_and_declarations_are_literal(changelog_module, block): + """Raw block types 3 to 5 render literally, like
, so a heading inside
+    one is a sample and not a release."""
+    text = f"## 1.0\n\n{block}\n\n- real note\n"
+    assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]
+    assert "real note" in changelog_module.find_release_notes(text, "1.0").body
+
+
+def test_headings_need_a_space_or_tab_after_the_hashes(changelog_module):
+    """A non-breaking space pasted from rich text renders as ordinary text, so
+    the line must not end the release above it."""
+    text = "## 1.0\n\n- real note\n\n## 9.9.9\n\n- not a release\n"
+    assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]
+    assert changelog_module.find_release_notes(text, "9.9.9") is None
+    # A tab is valid and still opens a heading.
+    tabbed = "## 1.0\n\n- one\n\n##\t2.0\n\n- two\n"
+    assert [e.version for e in changelog_module.parse_changelog(tabbed)] == ["1.0", "2.0"]
+
+
+def test_preview_skips_every_raw_block_form():
+    """The extractor tracks the same block forms as the parser, so a sample
+    bullet inside one cannot become the collapsed headline."""
+    src = PREVIEW.read_text(encoding = "utf-8")
+    assert "RAW_BLOCKS" in src
+    assert "CDATA" in src and "[A-Za-z]" in src
+
+
+@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER])
+def test_expanded_popup_fits_a_short_viewport(banner):
+    """A window under roughly 430px high used to push the card's title and
+    dismiss control above the top of the screen."""
+    panel = PANEL.read_text(encoding = "utf-8")
+    # The notes region shrinks inside the capped card, so header and actions stay on screen.
+    assert "min-h-0 flex-1" in panel, "notes height must follow the viewport"
+    src = banner.read_text(encoding = "utf-8")
+    assert "max-h-[calc(100dvh_-_2rem)]" in src, "card is the backstop on tiny viewports"
+
+
+def test_relative_changelog_links_point_at_the_repository():
+    """CHANGELOG.md links are repository-relative. Rendered as-is they resolve
+    against Studio's origin, so the renderer blocks them."""
+    src = LINKS.read_text(encoding = "utf-8")
+    assert "https://github.com/unslothai/unsloth/blob/main/" in src
+    assert "https://raw.githubusercontent.com/unslothai/unsloth/main/" in src
+    # Absolute targets, fragments, fenced code and code spans stay untouched.
+    assert "ABSOLUTE" in src and "codeSpans" in src and "FENCE" in src
+    panel = PANEL.read_text(encoding = "utf-8")
+    assert "resolveChangelogLinks" in panel
+
+
+@pytest.mark.parametrize("query", ["latest", "main", "not-a-version", "abc"])
+def test_unparseable_versions_are_rejected(changelog_module, query):
+    """Sections are indexed only when their version parses, so a query that
+    cannot parse can never match and is a bad request, not an empty result."""
+    assert changelog_module.is_supported_version_query(query) is False
+
+
+@pytest.mark.parametrize("query", ["2026.7.5", "v2026.7.5", "2026.07.5", "1.0.0rc1"])
+def test_real_versions_are_still_accepted(changelog_module, query):
+    assert changelog_module.is_supported_version_query(query) is True
+
+
+def test_reference_style_images_resolve_to_the_raw_host():
+    """`![alt][arch]` with `[arch]: docs/arch.png` needs the raw file: the blob
+    URL is an HTML page, so the image would not load."""
+    src = LINKS.read_text(encoding = "utf-8")
+    assert "IMAGE_REFERENCE" in src
+    assert "imageLabels" in src
+
+
+def test_collapsed_notes_surface_is_hidden_when_nothing_previews():
+    """Notes that are only a fenced command block preview as nothing, and an
+    empty muted strip is worse than no strip."""
+    src = PANEL.read_text(encoding = "utf-8")
+    assert "preview?.items.length === 0" in src
+
+
+def test_a_fence_closer_accepts_only_spaces_and_tabs(changelog_module):
+    """A delimiter followed by a non-breaking space is code content, so it must
+    not close the block and let a sample heading through."""
+    text = "## 1.0\n\n```\n```\u00a0\n## 9.9.9\n```\n\n- real note\n"
+    assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]
+    plain = "## 1.0\n\n```\nx\n```\t\n\n## 2.0\n\n- two\n"
+    assert [e.version for e in changelog_module.parse_changelog(plain)] == ["1.0", "2.0"]
+    # The same rule in both frontend scanners.
+    for source in (PREVIEW, LINKS):
+        assert "/[^ \\t]/" in source.read_text(encoding = "utf-8")
+
+
+def test_code_spans_close_on_a_run_of_equal_length():
+    """`a``b [x](y.md)` is one code span, so the link inside it is literal."""
+    src = CODE_SPANS.read_text(encoding = "utf-8")
+    assert "candidate === ticks" in src, "closer length must match the opener"
+    # Shared, so the preview and the link resolver cannot drift apart.
+    assert "markdown-code-spans" in PREVIEW.read_text(encoding = "utf-8")
+    assert "markdown-code-spans" in LINKS.read_text(encoding = "utf-8")
+
+
+def test_preview_decodes_entities_like_the_renderer():
+    """Streamdown renders `AT&T` as AT&T, so the collapsed preview must
+    not show the raw entity."""
+    src = PREVIEW.read_text(encoding = "utf-8")
+    assert "NAMED_ENTITIES" in src and "decodeEntity" in src
+    # Decoded before code spans are restored, so code keeps the literal text.
+    assert src.index(".replace(ENTITY, decodeEntity)") < src.index(".replace(PARKED")
+
+
+def test_release_notes_request_refreshes_an_expired_token():
+    """A direct fetch cannot recover from a 401; authFetch refreshes first."""
+    src = NOTES_HOOK.read_text(encoding = "utf-8")
+    assert "authFetch(" in src
+    assert "getAuthToken" not in src
+
+
+def test_preview_handles_the_desktop_updater_line_endings():
+    """The updater body arrives with CRLF, which used to hide fences from the
+    extractor and promote a code sample to a headline."""
+    src = PREVIEW.read_text(encoding = "utf-8")
+    assert "LINE_ENDINGS" in src
+    assert "LINE_ENDINGS" in LINKS.read_text(encoding = "utf-8")
+
+
+def test_preview_renders_reference_links_as_text():
+    """`[text][label]` and `![alt][label]` render as a link and an image, so
+    the preview must not show their raw markup."""
+    src = PREVIEW.read_text(encoding = "utf-8")
+    assert "LINK_REFERENCE" in src and "IMAGE_REFERENCE" in src
+    # A definition line renders as nothing, so it is not a preview item.
+    assert "DEFINITION" in src
+
+
+def test_preview_treats_escaped_punctuation_as_literal():
+    """`\\*not italic\\*` keeps its stars and an escaped backtick does not open
+    a code span."""
+    assert "ESCAPE" in PREVIEW.read_text(encoding = "utf-8")
+    assert "escaped(" in CODE_SPANS.read_text(encoding = "utf-8")
+
+
+def test_link_resolver_skips_every_code_form():
+    """Indented code and code spans crossing a line render as code, so their
+    contents must not be rewritten."""
+    src = LINKS.read_text(encoding = "utf-8")
+    assert "INDENTED_CODE" in src
+    # Spans are scanned over the whole document, not line by line.
+    assert "codeSpans(masked)" in src
+    # A definition cannot interrupt a paragraph.
+    assert "definition.has(index)" in src
+
+
+def test_badge_links_resolve_both_targets():
+    """`[![alt](img)](link)` is the badge idiom: the outer link used to stay
+    relative because the label was not allowed to nest."""
+    assert "NESTED_LABEL" in LINKS.read_text(encoding = "utf-8")
+
+
+def test_in_flight_requests_are_identified_not_just_versioned():
+    """Two requests for the same version could resolve out of order and leave
+    the panel showing the older result."""
+    assert "requestIdRef" in NOTES_HOOK.read_text(encoding = "utf-8")
+
+
+def test_notes_repair_the_shared_previews_width_reset():
+    """MarkdownPreview clears max-width on every descendant, so a wide image
+    and the renderer's own link dialog escape the card."""
+    src = PANEL.read_text(encoding = "utf-8")
+    assert "[&_img]:max-w-full" in src
+    assert "[&_[data-streamdown=link-safety-modal]>*]:max-w-md" in src
+
+
+@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER])
+def test_only_the_notes_region_scrolls(banner):
+    """The dismiss control sits inside the card, so scrolling the card itself
+    carried it off screen on a short viewport."""
+    src = banner.read_text(encoding = "utf-8")
+    assert "flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden" in src
+    assert 'className="min-h-0 flex-1"' in src
+    panel = PANEL.read_text(encoding = "utf-8")
+    assert "max-h-64 min-h-0 flex-1 overflow-y-auto" in panel
+
+
+def test_a_comment_marker_in_prose_cannot_swallow_later_releases(changelog_module):
+    """A note that mentions `\n\n- note\n"
+    assert [e.version for e in changelog_module.parse_changelog(hidden)] == ["2.0"]
+
+
+def test_unmatched_backtick_runs_stay_linear(changelog_module):
+    """Rescanning the suffix for every opener was quadratic: a line of runs of
+    1, 2, 3 ... backticks, none of which ever closes, took 7.7s at 321 KB and
+    is reparsed on every popup request, so one malformed remote changelog could
+    tie up backend workers."""
+    line = "".join("`" * (i + 1) + "x" for i in range(800))
+    assert len(line) > 300_000
+    started = time.monotonic()
+    assert changelog_module._code_span_ranges(line) == []
+    assert time.monotonic() - started < 2.0
+
+
+def test_a_base_exception_releases_the_single_flight_flag(changelog_module, monkeypatch):
+    """The flag was cleared only after `except Exception`, so a BaseException
+    (KeyboardInterrupt, SystemExit, CancelledError) stranded it and every later
+    caller then waited out the full deadline for the life of the process."""
+    changelog_module.reset_changelog_cache()
+
+    def explode():
+        raise KeyboardInterrupt
+
+    monkeypatch.setattr(changelog_module, "_fetch_remote_changelog", explode)
+    with pytest.raises(KeyboardInterrupt):
+        changelog_module.get_remote_changelog()
+    assert changelog_module._remote_fetching is False
+    changelog_module.reset_changelog_cache()
+
+
+@pytest.mark.parametrize("marker", ["", ""])
+def test_an_empty_comment_does_not_swallow_later_releases(changelog_module, marker):
+    """`` and `` are complete comments in CommonMark: the closer
+    overlaps the opener. Searching for `-->` past the opener missed them, so an
+    empty comment used as a section marker hid every release below it."""
+    text = f"## 2.0\n\n- new stuff\n\n{marker}\n\n## 1.0\n\n- old stuff\n"
+    assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"]
+    assert changelog_module.find_release_notes(text, "1.0") is not None
+    assert "old stuff" not in changelog_module.find_release_notes(text, "2.0").body
+    # The frontend scanner has to agree, or the preview and the body disagree.
+    assert "!line.includes(COMMENT_CLOSE)" in PREVIEW.read_text(encoding = "utf-8")
+
+
+def test_an_unterminated_comment_still_hides_the_rest(changelog_module):
+    """The fix must not turn every `` or `
` is not a release.""" + for text in ( + "## 1.0\n\n## 9.9.9\n\n- note\n", + "## 1.0\n\n
\nx\n
## 9.9.9\n\n- note\n", + ): + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + + +def test_an_exact_heading_is_never_shadowed(changelog_module): + """PEP 440 says 1.0 == 1.0.0, so the normalised match used to win even + when the file had a section spelled exactly as asked.""" + text = "## 1.0.0\n\n- padded\n\n## 1.0\n\n- exact\n" + assert changelog_module.find_release_notes(text, "1.0").body == "- exact" + assert changelog_module.find_release_notes(text, "1.0.0").body == "- padded" + # Normalised matching still applies when there is no exact heading. + assert changelog_module.find_release_notes("## 2026.7.6\n\n- x\n", "2026.07.6") is not None + + +def test_setext_headings_are_release_boundaries(changelog_module): + """A version over a line of dashes is the same heading in setext form.""" + text = "2.0\n---\n\n- new\n\n1.0\n---\n\n- old\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + assert changelog_module.find_release_notes(text, "2.0").body == "- new" + # A rule between sections is still a rule, and a setext h1 is not a release. + assert [ + e.version + for e in changelog_module.parse_changelog("## 2.0\n\n- a\n\n---\n\n## 1.0\n\n- b\n") + ] == ["2.0", "1.0"] + + +def test_a_long_backtick_run_does_not_stall_the_parser(changelog_module): + """The code-span guard used to backtrack: 20k backticks took over a minute + and every request re-parsed the file.""" + import time + + text = "## 1.0\n\n- " + "`" * 20_000 + " " * 16_000 + assert len(line) < changelog_module.CHANGELOG_MAX_BYTES + started = time.monotonic() + visible, in_comment = changelog_module._strip_comments(line, False, False) + elapsed = time.monotonic() - started + # Roughly 40ms scanning forward against roughly 11s restarting each time. + assert elapsed < 2.0, f"comment stripping took {elapsed:.1f}s" + # Same result as before: the spans survive and the comments are gone. + assert in_comment is False + assert "`\n- See [docs](docs/a.md)\n") + assert repo in spanned + # A comment starting a line is a block: it hides down to the closer's line, that line included. + block = run_scanner("links", "\n") + assert repo not in block + closer = run_scanner("links", " See [docs](docs/a.md)\n") + assert repo not in closer + + +def test_a_bare_level_two_marker_ends_the_release(changelog_module, run_scanner): + """An ATX heading's opening sequence may be followed by the end of the line + (spec 0.31.2 section 4.2), so a bare `##` is an empty level-two heading. The + scanners required whitespace after the hashes, so everything below such a + line stayed inside the release above it and the popup showed unrelated notes + under that version.""" + text = "## 2.0\n\n- new thing\n\n##\n\n- SECRET: not part of 2.0\n" + entry = changelog_module.find_release_notes(text, "2.0") + assert "new thing" in entry.body + assert "SECRET" not in entry.body + # An empty heading has no version, so it ends a release without indexing one. + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0"] + # Prose still needs a space or a tab: `##x` is a paragraph, not a heading. + prose = "## 2.0\n\n- new thing\n\n##x\n\n- still 2.0\n" + assert "still 2.0" in changelog_module.find_release_notes(prose, "2.0").body + # The preview agrees: an empty heading renders as nothing, so it ends the bullet. + preview = run_scanner("preview", "- new thing\n##\nUnrelated scratch notes\n") + assert preview_leads(preview) == ["new thing"] + + +def test_a_comment_between_bullets_closes_the_list(changelog_module, run_scanner): + """A comment is an HTML block (spec 0.31.2 section 4.6, type 2), so one + written at the margin under a bullet is not indented enough to continue that + item and closes the list. The scanners blanked the line before list tracking + saw it, which reads as a blank line and leaves the item open, so the release + heading below it looked like nested item content and the new release was + merged into the one above.""" + text = "## 1.0\n\n- old item\n\n ## 2.0\n\n- new item\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] + assert "new item" not in changelog_module.find_release_notes(text, "1.0").body + assert "new item" in changelog_module.find_release_notes(text, "2.0").body + # At the item's content column the comment stays inside it, so the heading under it is nested. + nested = "## 1.0\n\n- old item\n \n ## 2.0\n\n- new item\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] + # The link resolver reads the same column: list closed, four spaces is code, left untouched. + code = run_scanner("links", "- old item\n\n [guide](docs/a.md)\n") + assert "[guide](docs/a.md)" in code and "github.com" not in code + # Inside the item those four spaces are two columns in, so it is prose and the link resolves. + prose = run_scanner("links", "- old item\n \n [guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in prose + # The preview agrees: the fence is indented code, not a fence swallowing the bullet below. + preview = run_scanner( + "preview", + "- Details:\n\n ```\n - hidden sample\n- Real second item\n", + ) + assert preview_leads(preview) == ["Details:", "Real second item"] + + +def test_a_parenthesised_link_destination_still_resolves(run_scanner): + """A destination may hold parentheses while they balance (spec 0.31.2 + section 6.3), so `[x]((draft).md)` points at `(draft).md`. The resolver's + destination expression stopped at the first paren, matched an empty + destination and left the markdown alone, so the link resolved against + Studio's own origin instead of the repository.""" + leading = run_scanner("links", "[details]((draft).md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/(draft).md" in leading + # An image resolves against the raw host the same way. + image = run_scanner("links", "![shield]((badge).png)\n") + assert "https://raw.githubusercontent.com/unslothai/unsloth/main/(badge).png" in image + # A pair in the middle of a path balances too. + middle = run_scanner("links", "[api](docs/(v2)/api.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/(v2)/api.md" in middle + # An unbalanced paren makes the destination invalid, so `[x](a(b.md)` is plain text, not a link. + unbalanced = run_scanner("links", "[x](a(b.md)\n") + assert unbalanced == "[x](a(b.md)\n" + # One more closer balances the pair, and then it is a link again. + closed = run_scanner("links", "[x](a(b.md))\n") + assert "https://github.com/unslothai/unsloth/blob/main/a(b.md)" in closed + # Pairs nest, and one level was all the expression allowed, so a path with two stayed relative. + nested = run_scanner("links", "[x](((draft)).md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/((draft)).md" in nested + deep = run_scanner("links", "![shot](((((v2))))).png)\n") + assert "https://raw.githubusercontent.com/unslothai/unsloth/main/((((v2))))" in deep + # The closer must still be there: an unbalanced run below a nested pair is not a link. + across = run_scanner("links", "[x](((a).md\n[y](docs/y.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/y.md" in across + assert "[x](((a).md" in across + + +def test_a_fence_inside_a_container_still_hides_its_sample(run_scanner): + """A fence is measured from its container and not from the margin (spec + 0.31.2 section 4.5), so `> ~~~` and a fence three columns under a nested + bullet open one. Reading the margin instead never saw them, so the sample + inside was treated as prose and a relative link written in a code block was + rewritten into the text the reader sees verbatim.""" + quoted = run_scanner("links", "> ~~~\n> [guide](docs/a.md)\n> ~~~\n") + assert "[guide](docs/a.md)" in quoted and "github.com" not in quoted + nested = run_scanner("links", "- a\n - b\n ~~~\n [x](docs/x.md)\n ~~~\n") + assert "[x](docs/x.md)" in nested and "github.com" not in nested + # A longer closer is still a closer, so the pair is not something a code span hid. + uneven = run_scanner("links", "> ```\n> [guide](docs/a.md)\n> ````\n") + assert "[guide](docs/a.md)" in uneven and "github.com" not in uneven + # The fence ends with its container: a line outside the quote, or left of the item, is Markdown. + left = run_scanner("links", "> ~~~\n[guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in left + dedented = run_scanner("links", "- a\n ~~~\n[guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in dedented + # A document-level fence owns the quoted lines below, so the marker does not undo it. + document = run_scanner("links", "~~~\n> [guide](docs/a.md)\n~~~\n") + assert "[guide](docs/a.md)" in document and "github.com" not in document + # Four columns past the item's content column it is indented code, not a fence: still literal. + code = run_scanner("links", "- Details:\n\n ~~~\n [guide](docs/a.md)\n") + assert "[guide](docs/a.md)" in code and "github.com" not in code + + +def test_an_html_block_inside_a_container_is_literal_too(run_scanner): + """Type 1 and type 6 blocks are measured from their container the same way, + so a `
` under a nested bullet and a `
` inside a quote both
+    show their contents verbatim. Missing the opener treated the body as
+    Markdown and rewrote the literal examples in it."""
+    nested = run_scanner("links", "- a\n  - b\n    
\n [x](docs/x.md)\n
\n") + assert "[x](docs/x.md)" in nested and "github.com" not in nested + quoted = run_scanner("links", ">
\n> [x](docs/x.md)\n> 
\n") + assert "[x](docs/x.md)" in quoted and "github.com" not in quoted + # The block ends with its container, so a line dedented out of the item is Markdown again. + dedented = run_scanner("links", "- a\n - b\n
\n[x](docs/x.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/x.md" in dedented + # Inside a quote a bare marker holds nothing, the blank line that ends a type 6 block. + blank = run_scanner("links", ">
\n>\n> [x](docs/x.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/x.md" in blank + + +def test_an_underline_left_of_an_item_is_lazy_text_of_it(changelog_module, run_scanner): + """A setext underline may never be a lazy continuation line (spec 0.31.2 + section 4.3), so `===` written left of an open list item is read as more of + the item's paragraph rather than as a block that closes it. Rejecting every + underline-shaped line ended the list there, which promoted the nested + "## 2.0" below it to a document-level heading and indexed a release the + renderer never shows.""" + nested = "## 1.0\n- old note\n===\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] + # A row of dashes is a thematic break, closing the item, so the heading is the next release. + broken = "## 1.0\n- old note\n---\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(broken)] == ["1.0", "2.0"] + # With no paragraph above it the underline opens one, so the blank line closes the item. + apart = "## 1.0\n- old note\n\n===\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(apart)] == ["1.0", "2.0"] + # The link scanner keeps the item open, so the four-space line is a paragraph and resolves. + resolved = run_scanner("links", "- Details:\n===\n\n [guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in resolved + + +def test_a_quote_keeps_its_paragraph_to_itself(changelog_module, run_scanner): + """Lazy continuation runs the other way too: a marker written outside a + blockquote is not text of the quote's paragraph, so `2. item` under + `> quote` opens a list even though an ordered marker past 1 may not + interrupt a paragraph (spec 0.31.2 section 5.2). Lending the quote's + paragraph to the document left the list closed, so the heading indented to + the item's content column read as a release of its own.""" + quoted = "## 1.0\n> quote\n2. item\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(quoted)] == ["1.0"] + # A quote holding a heading leaves no paragraph, nor does an empty one, so the list opens. + heading = "## 1.0\n> # inner\n2. item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(heading)] == ["1.0"] + # An unquoted line the quote's paragraph swallows keeps it open, the marker still outside. + lazy = "## 1.0\n> quote\ntext\n2. item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(lazy)] == ["1.0"] + # Under an ordinary paragraph the marker is its text, so no list opens and the heading is real. + prose = "## 1.0\nprose\n2. item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(prose)] == ["1.0", "2.0"] + # The preview reads the marker as a bullet for the same reason. + assert preview_leads(run_scanner("preview", "> quote\n2. item\n")) == ["item"] + + +def test_indented_code_before_an_ordered_marker_still_opens_a_list(changelog_module): + """An indented code block ends at the first line that is not indented enough + to continue it, and no paragraph is open for the marker below to continue, + so `2. item` opens a list whatever its start number. Reading it as text of + the code block instead would leave the list closed and index the heading at + the item's content column as a release.""" + joined = "## 1.0\n\n code\n2. item\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(joined)] == ["1.0"] + # A blank line between the two changes nothing: the list opens either way. + apart = "## 1.0\n\n code\n\n2. item\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(apart)] == ["1.0"] + # Four columns past its container the marker is code, so no list opens and the heading stands. + inside = "## 1.0\n\n code\n - item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(inside)] == ["1.0", "2.0"] + + +def test_a_fence_written_as_an_item_first_content_opens_in_that_item(run_scanner): + """A block written straight after a list marker is the item's own first + content, measured from the column that content starts (spec 0.31.2 section + 5.2), so "- ```md" opens a fence. Reading the whole line instead never saw + one, so the code sample below it was treated as prose: the resolver rewrote + a destination the reader sees verbatim, and the preview offered the info + string as a headline bullet.""" + sample = run_scanner("links", "- ```md\n [example](docs/a.md)\n ```\n") + assert "[example](docs/a.md)" in sample and "github.com" not in sample + ordered = run_scanner("links", "1. ~~~\n [example](docs/a.md)\n ~~~\n") + assert "[example](docs/a.md)" in ordered and "github.com" not in ordered + # The preview agrees: an item of only a code block previews as nothing; the next is a bullet. + preview = run_scanner("preview", "- ```md\n sample text\n ```\n- Added tests\n") + assert preview_leads(preview) == ["Added tests"] + # One column further in it is indented code inside the item, so the link is prose and resolves. + padded = run_scanner("links", "- ```\n [example](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in padded + # A marker the paragraph above swallows opens no item, so no fence: ordered items open at 1. + lazy = run_scanner("links", "Intro.\n2. ```\n[guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in lazy + + +def test_an_html_block_ends_with_the_item_it_was_written_in(changelog_module, run_scanner): + """An HTML block holds no lazy continuation line, so one opened on a list + item's continuation line ends where the item does, exactly as a fence there + does. Ending it only on a blank line let it run past the item and swallow + the next release heading, so those notes could never be found, and the + collapsed preview lost every bullet below it.""" + text = "## 1.0\n\n- item\n\n
\n## 2.0\n\n- new thing\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] + assert "new thing" in changelog_module.find_release_notes(text, "2.0").body + # A raw block such as
 is scoped the same way.
+    raw = "## 1.0\n\n- item\n\n  
\n## 2.0\n\n- new thing\n"
+    assert [e.version for e in changelog_module.parse_changelog(raw)] == ["1.0", "2.0"]
+    # At the item's content column the block holds the heading, which is nested and indexes nothing.
+    nested = "## 1.0\n\n- item\n\n  
\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] + # The preview reads it the same way: the bullet below the block is a bullet. + preview = run_scanner("preview", "- item\n\n
\n- Added tests\n") + assert preview_leads(preview) == ["item", "Added tests"] + # An opener straight after a marker opens in that item, so the dedented heading is a release. + marked = "## 1.0\n\n-
\n## 2.0\n\n- new thing\n" + assert [e.version for e in changelog_module.parse_changelog(marked)] == ["1.0", "2.0"] + + +def test_a_comment_may_close_on_a_later_line_of_its_paragraph(run_scanner): + """A comment written mid-sentence is inline raw HTML belonging to the + paragraph around it, so its `-->` may arrive on a later line of that same + paragraph and everything between renders as nothing. Ending the comment at + its own line left a backtick inside it pairing with a real one below, which + hid a following link from the resolver, and left the collapsed preview + quoting text the popup body does not show.""" + carried = run_scanner("links", "Note see [d](docs/a.md) and `x`\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in carried + # Text inside the comment renders as nothing, so it is left alone. + inside = run_scanner("links", "Note end\n") + assert "[c](docs/c.md)" in inside and "github.com" not in inside + # The preview hides it too, rather than quoting the comment at the reader. + preview = run_scanner( + "preview", "- Added X \n- Second\n" + ) + assert preview_leads(preview) == ["Added X", "Second"] + # An opener cannot outlive its paragraph: with it closed the ` end [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in broken + # A heading breaks into the paragraph, so it ends the comment's reach too. + headed = run_scanner("links", "Note end [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in headed + assert preview_leads(run_scanner("preview", "Note ` written on a line of + its own, and a wrapped line may open with emphasis. The guard asking whether + the closer is reachable read any line whose first character was punctuation + as the start of a new block, so neither shape counted as more of the + paragraph carrying the comment. The comment then never closed, and the + collapsed popup showed the author's internal note to the user.""" + closer = run_scanner( + "preview", + "- DoRA training is available in Studio. \n", + ) + assert preview_leads(closer) == ["DoRA training is available in Studio."] + # A continuation may open with emphasis, which is text and not a block. + starred = run_scanner( + "preview", + "- DoRA training is available. \n", + ) + assert preview_leads(starred) == ["DoRA training is available."] + underscored = run_scanner( + "preview", + "- DoRA training is available. \n", + ) + assert preview_leads(underscored) == ["DoRA training is available."] + # A real block still ends the paragraph, so the opener below one is text and hides nothing. + broken = run_scanner("links", "Note [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in broken + # So does a list item with content, which may interrupt a paragraph. + item = run_scanner("links", "Note [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in item + + +def test_a_comment_written_as_an_item_first_content_is_a_block(changelog_module, run_scanner): + """A comment is an HTML block (spec 0.31.2 section 4.6, type 2), so one + written as a list item's first content opens inside that item, exactly as a + fence written there does. The scanners looked for the opener at the margin + of the line as written, so a marker in front of it hid the block: the + resolver rewrote a destination inside raw HTML, which Streamdown then shows + the reader as a literal URL, and the preview quoted the hidden note back at + them as though the bullet were Markdown.""" + item = run_scanner("links", "- AMD support, see [the guide](docs/amd.md)\n") + assert item == "- AMD support, see [the guide](docs/amd.md)\n" + # Every marker opens an item, and a nested one is still an item. + for text in ( + "* see [the guide](docs/amd.md)\n", + "1. see [the guide](docs/amd.md)\n", + "- outer\n - see [the guide](docs/amd.md)\n", + ): + assert "github.com" not in run_scanner("links", text) + # The multiline form hides lines to the closer, as a comment at the item's content column did. + multiline = run_scanner("links", "- \n") + assert "[a](docs/x.md)" in multiline and "github.com" not in multiline + # Still scoped to the item it was written in, so a line dedented out of it ends the block. + dedented = run_scanner("links", "- hidden note\n- Real bullet\n") + assert preview_leads(preview) == ["Real bullet"] + # The parser agrees too: the item keeps its column, so a heading inside is nested, not indexed. + text = "## 1.0\n\n-