mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 00:33:49 +00:00
* Studio: add a loaded models indicator with per-model eject
A loaded model is only visible on the page that loaded it. Leave Chat for the
Hub or Train and nothing on screen says a model is still holding memory, so the
only way to check or release it is to navigate back.
Adds a compact card in the bottom-right listing everything resident, with an
eject button per row. Chat, images, video and dictation each own a runtime and
their own /status, so all four are read and merged. A TTS load is split out from
a chat load by audio_type: same picker, but it does not answer prompts.
It joins the existing bottom-right stack in provider.tsx alongside the update
banners and the download panel, so nothing overlaps. Collapses to a pill, and
that choice persists. On by default, with a toggle in Settings, General,
Notifications.
Reads fail soft per runtime, so a chat-only host missing /video keeps its other
rows. Ejecting the active chat model reuses the API monitor's read, unload,
re-read sequence, since an API auto-switch can replace the model mid-eject.
* Studio: address review on the loaded models indicator
Eject the row's own model, not whatever is active. An API auto-switch can land
between the poll and the click, and re-reading unconditionally would free a
model nobody asked to free while reporting the clicked one as ejected. The
sequence moves to eject-chat-model.ts, taking its I/O as deps like
api-monitor/unload-resident.ts, so the scoping is under test.
Fall back to the top-level STT fields. Servers predating the per-engine split
report the resident Transformers model only there, so those installs showed no
dictation row and had no eject for it.
Bound each status read. authFetch has no timeout, so a runtime that accepts the
connection and never answers left the Promise.all pending forever, and with it
the in-flight guard that gates every later refresh.
Re-sync Images and Video after an eject. Both hold their own status and re-read
it on tab activation, not on a timer, so ejecting from the indicator left their
controls offering to generate on a freed runtime.
Add both preference keys to "Reset all local preferences", which otherwise left
the indicator hidden or collapsed after a reset.
* Studio: verify the row before the identity-less unloads
/images/unload, /video/unload and the STT unload carry no model id and release
whatever their runtime holds, so acting on a row up to one poll old could free a
model nobody clicked while reporting the clicked one as ejected. The image and
video races need a load landing in that window; dictation loads on demand and
releases when idle, so its engine can change with no user action at all.
Each now re-reads its runtime and only unloads when the resident model still
matches the row. This narrows the window to the round trip rather than closing
it, which would take a backend that accepts the model id.
Ejecting reports an outcome instead of a bare string, so a stale row says the
model is already gone and names what took its place, rather than claiming a
success it did not perform. An unreadable dictation status is now an error
rather than a false success.
* Studio: stop dictation rows printing their engine twice
The llama.cpp and whisper.cpp STT sidecars report their engine name as the
device (stt_mtmd_sidecar.device is "llama.cpp", stt_ggml_sidecar.device is
"whisper.cpp"), so a row read "Dictation - llama.cpp - llama.cpp". Only the
Transformers sidecar reports a real device.
joinDetail now drops repeated parts, so the engine is named once and a genuine
device still shows beside it.
* Studio: make the loaded models card movable and keep it off the Live monitor
The card and the Live monitor both anchor to the bottom-right, so opening the
monitor buried the card underneath it. The monitor is draggable and resizable,
so a fixed offset would only hold until it moved.
The monitor now publishes its box. The corner stack lifts above it, but only
while it is in the stack's column and low enough to be in the way, so a monitor
dragged elsewhere leaves the corner alone. This clears the update banners and
the download panel too, which had the same overlap.
The card also gets a drag handle: anchored to its corner until moved, then kept
where it was left, clamped back into view if the window shrinks. Its position
persists and joins the reset list.
* Studio: use the AI brain icon and lead the Notifications section
The loaded models card and the Train wizard's Model step both take
AiBrain01Icon, and the indicator toggle moves above the llama.cpp one so the
Notifications section leads with it.
* Studio: let the collapsed indicator be dragged too
The drag handle only existed in the expanded header, so the pill could not be
moved. The pill is now its own handle: a press becomes a drag past a few
pixels, below that it stays the click that expands the card, and reading the
flag clears it so a keyboard activation is not swallowed.
Re-clamping now also runs on a ResizeObserver, so expanding a pill dragged to
the bottom edge pulls the taller card back into view instead of growing off
screen.
* Studio: address review on the loaded models indicator
Keep the Chat picker selection when an eject leaves the model resident, so a
reload during the run no longer empties the picker while the model still runs.
Republish the Live monitor's frame from a layout effect. ResizeObserver does
not fire for a position-only change, so dragging the monitor left the overlay
stack dodging its old corner.
Cap the overlay stack's height to the space above its own bottom inset. Lifting
it over the monitor without shortening it pushed the top of a long download
list off screen.
Measure the stack column from the update banners (448px), not the download
panel (400px), so a monitor beside a banner is still dodged.
Name the precision an image or video pipeline loaded at, so a quantised build
is distinguishable from bf16 in the row.
* Studio: make the loaded models card resizable
The card is anchored bottom-right, where a native CSS resize grip has nowhere
to grow, so it resizes from a grip at the leading corner instead: the anchored
corner is held still and the box opens up and to the left. The grip shares the
title icon's slot rather than adding another control to a small header, and
double-clicking it returns the card to its default size and corner.
The size persists next to the position, is clamped to a floor and to the room
available, and is cleared by Reset all local preferences.
* Studio: open a loaded model's page from its row
Clicking a row now goes to where that model is used: chat models to Chat,
image and video pipelines to their tabs, dictation to the Voice settings tab
that drives the sidecars. The target follows the runtime holding the weights,
not the kind, so a Whisper checkpoint in the chat slot goes to Chat rather
than to dictation.
Navigation carries no search params, so it only changes page: it does not
start a new thread or reload anything. Dictation moves to the Audio page once
that lands.
* Studio: revert the resizable loaded models card
Back to the fixed-size card. Reverts 96749d1f.
Also read /api/inference/status directly instead of through the chat barrel,
matching the dictation read alongside it. The server logs show the indicator's
chat read never reaching the backend: across every session the status count
sits at roughly half the images/video/dictation count, matching the chat
page's own 10s poll rather than the indicator's 5s one, which is why a loaded
text model showed no row while an image model did.
* Studio: keep the loaded models card reachable, and stop the ejects that report work they did not do
Two of these break the feature outright, the rest report success for an eject
that never happened.
The card can become permanently unreachable. use-drag-position captured
panelRef.current once, in an effect keyed [position, reclamp]. The indicator
returns null until the first poll produces a row, so that effect always ran
while the ref was still null: no ResizeObserver was ever constructed, and the
resize listener closed over null. Neither dependency changes when the node
finally mounts, so it never re-ran, and nothing in production ever called
clampToViewport except an active drag. Dragging the card on a 2560x1440 screen
and reopening at 1440x900 left it at x=2502, off screen, taking its own drag
handle and collapse button with it; resizing did not help. The only way back
was devtools or Reset all local preferences. The node now arrives through
state so the effect re-subscribes, and the stored position is clamped as it is
read so it cannot paint off screen even for one frame.
Locale parity fails. CI's first gate is i18n:check:strict and the two new keys
were added to en.ts only, so all 11 overlays were short. Added everywhere.
The chat eject reported ejects it never performed. When nothing matching was
resident it POSTed /unload anyway and returned stillResident: null without
re-reading. The backend answers 200 "unloaded" for a model it never held, so
the user got a success toast for a no-op and their picker selection was
cleared off the back of it. Reachable by an ordinary sequence: loading B
evicts A, and the card shows A for up to one poll. It now reports the same
replaced / already-gone outcomes the runtime rows use, and only names a model
directly for a cached row, which is the one case a status read cannot see.
The dictation eject could release another engine's model. /audio/stt/unload
silently serves gguf from the transformers engine when whisper-server is
absent, and the unload response body is a fixed {loaded_model: null}, so a 200
was not evidence this engine let go. It now re-reads and reports what the
engine actually holds.
No timeout on the eject path. READ_TIMEOUT_MS guarded only readLoadedModels; a
runtime that accepted the connection and never answered left the row disabled
with a spinner until reload. Same bound, raised rather than swallowed.
An audio-input VLM was filed under Speech. The split was audio_type !==
"whisper", but the backend's own is_audio_input_type names two input types,
whisper and audio_vlm. A model that listens was labelled as one that speaks,
which is the confusion this card exists to remove.
A stale poll could restore an ejected row, and the trailing refresh could not
repair it because the in-flight guard was still held by that same read. A
generation counter retires reads issued before an eject, and a pending flag
keeps the trailing refresh.
The drag now captures the pointer, as the Live monitor's does. Without it a
release over another window is never delivered and the card follows the cursor.
Backend: the card polls four runtimes every 5s for as long as the app is open,
and only /api/inference/status was in _QUIET_POLL_PATHS. Its three siblings
are now too, or every desktop tauri.log carries three lines every five seconds.
Tests
tests/loaded-models-platform-matrix.test.ts walks the payloads
[Windows, Linux, WSL, macOS] x [NVIDIA, AMD ROCm, Intel XPU, CPU-only, Apple]
actually produce. None of it is visible from a CUDA box: ROCm reports itself
as cuda, Apple reports mps, XPU reports xpu for images but cpu for dictation
because the STT device probe never checks torch.xpu, and the sd.cpp engine
omits model_kind and puts "gguf" in dtype.
tests/loaded-models-backcompat.test.ts covers the desktop app running against
an older wheel, which is a supported configuration: it ships its own frontend
bundle and adopts a separately installed backend. /video/status and the STT
mtmd block are both days old, so a 404 route, a missing engine block, the
pre-split top-level fields and absent optional fields all have to degrade
rather than blank the rows a working runtime returned. Also pins that fields
a future backend adds are ignored.
tests/loaded-models-drag-restore.test.ts guards the restore geometry and, by
reading the source, the wiring it depends on. Four of its cases fail against
the code before this commit.
tests/studio/playwright_loaded_models_indicator.py runs 28 checks in Chromium,
Firefox and WebKit, covering Chrome, Edge and the WebView2, WKWebView and
WebKitGTK the desktop app embeds. It stubs the four status endpoints with
page.route, so it needs no model, no GPU and no llama.cpp build, and can
therefore replay the AMD, Apple and CPU-only payloads on any runner. The
position restore is here rather than in the node suite because it needs a real
layout and a real ResizeObserver. Wired into the Linux, macOS and Windows UI
workflows alongside the existing permission runs. Against the code before this
commit it reports 25/28, failing exactly on the off-screen card and the
mislabelled audio-input model.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: read the indicator's rows in one call, so the eject checks stop racing them
Staging CI on macos-14 caught this: every check up to the eject passed, then
the run died on a 60s locator timeout.
rows() read count() and then indexed nth(i). The eject checks poll it while
the row is disappearing, which is the whole point of them, so the row could go
between the two calls and nth(1) would block for the full locator timeout
rather than reporting a shorter list. evaluate_all takes the snapshot in a
single evaluation, so there is no window.
card_text had the same count()-then-read shape; it is now bounded and returns
empty rather than blocking when the card is mid-change.
Test-only. Confirmed 28/28 in Chromium, Firefox and WebKit after the change.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: finish the second review round on the indicator
Run the media pages' full unload cleanup on an indicator eject, not just a
status refresh. Images and Video kept lastLoad and canReapply, so Reapply
still offered to reload the model that was just ejected, and the stale
quantisation stayed on screen. Both now share that cleanup with their own
Unload rather than duplicating half of it.
Cap the overlay stack under a monitor parked high in its column. Avoidance was
skipped entirely whenever the monitor's bottom sat above the midpoint, on the
grounds that the corner was free, but the stack grows upwards from the bottom
and a full download list plus the card reaches well past halfway. It is capped
to the gap below such a monitor instead, since lifting over it would be wrong.
stackBottomInset and stackMaxHeight now come from one stackGeometry call, so
the two cannot disagree about where the monitor is.
* Studio: stop the chat header claiming an evicted model is loaded
Loading an image or video model evicts the chat model: the GPU arbiter allows
one owner and logs 'evicting chat for diffusion'. Nothing on the chat page
said so. modelLoaded was '!!checkpoint && !modelLoading', which reads the
picker selection rather than what the backend holds, and the status poll's
null-active_model branch reset three capability flags but left the checkpoint
alone. So the header kept its green tick for a model that was gone and the
next prompt came back as a bare 'Request failed (400)'.
The store now tracks residentCheckpoint from /api/inference/status, published
in applyActiveModelStatusToStore so the poll and the readopt path share it,
and the header derives loaded from that. undefined until the first read, so
startup does not flash 'not loaded'; an external model is unaffected, having
nothing resident.
An eviction also raises a toast naming the model, since it is otherwise silent
and the card in the corner was the only thing reporting it.
* Studio: fix the header tick for a model that was evicted
The previous commit added residency tracking but changed the wrong
modelLoaded: that one gates image attachments. The tick in the header comes
from the model selector's own isLoaded, which was 'selected !== ""', so it
still lit up for a chat model an image load had already evicted.
The selector now takes an optional loaded prop, defaulting to the old rule so
the Images and Video pickers are untouched, and the chat header passes
residency through it. A test reads both the selector rule and the header call
site out of the source, since the failure mode here was wiring, not logic.
* Studio: show a loading model in the indicator, with the toast
The load toast appeared at once and the card up to five seconds later, so a
slow load looked like nothing was happening anywhere but the toast.
The four load calls now announce themselves. model-eject-events becomes
model-lifecycle-events, and withModelLoadNotice wraps loadModel,
loadDiffusionModel, loadVideoModel and loadSttModel, so every caller is
covered without each page remembering to and the announcement cannot drift
from the request. The indicator adds a row on the start event and re-reads on
the settle one, rather than waiting for its next tick.
Loads are also read from the polls that already carry them: /status reports
loading[] and the dictation status reports loading per engine. That covers a
load started in another tab or before the page opened, which no event can.
A loading row shows a spinner where the eject button goes, since there is
nothing resident to release yet, and a status row for the same runtime always
replaces the announced one.
* Studio: let an audio VLM stay a chat row, and keep an exiting monitor from clearing the live frame
Two review items from the latest round.
An audio VLM is a chat model. is_audio means TTS in this codebase -- both
ModelConfig constructors build it as `audio_type is not None and audio_type !=
"audio_vlm"`, llama.cpp keeps _is_audio False for csm/whisper/audio_vlm, and
mlx_inference says so outright: "audio_vlm (omni audio input; is_audio stays
False -- it means TTS and redirects in the chat route)". The frontend already
agrees elsewhere: isMultimodalResponse counts audio_vlm as multimodal chat
capability, which is what lets a user attach audio to a chat message.
So the audio types split three ways, not two: whisper is the ASR sidecar,
audio_vlm answers prompts and happens to listen, and the remaining four speak.
The previous rule sorted every audio type as speaks-or-listens and filed
Gemma 3n under Dictation. Nothing was user-visibly wrong, because is_audio is
never true for audio_vlm and the row already fell through to Chat -- but the
rule encoded a false invariant and the test pinned it as the contract, which is
the part worth fixing.
Reopening the Live monitor during its exit animation blanked the frame.
AnimatePresence defaults to sync mode, so the replacement panel mounts while
the old one is still animating out, and the old one unmounts last. Its cleanup
called setFrame(null) unconditionally, wiping the frame the replacement had
just published. Nothing republishes afterwards: the reconcile effect is
ResizeObserver-driven and the republish effect keys on layout, so a monitor
left sitting still never writes again. The overlay stack then dropped back to
the bottom-right corner and covered the open monitor, including its close
button.
The store now records which panel published the frame, and clearFrame is a
no-op unless that panel still owns it.
tests/monitor-frame-ownership.test.ts covers the reopen-during-exit case, a
lone close still clearing, the replacement clearing its own frame afterwards,
and that no-op writes still do not notify (the overlay stack re-renders on
every notification). Six of its seven cases fail without the change.
* Studio: keep the images and video loading rows up for the whole background load
Both loads only start the work: begin_load hands off to a daemon thread and the
POST returns at once, while /images/status and /video/status keep reporting
loaded: false until it finishes. withModelLoadNotice settled on the POST, so the
indicator row appeared and vanished within one round trip while the page toast
went on saying "loading" for minutes, which is the exact gap the notice exists
to close.
withBackgroundLoadNotice settles from the same load-progress endpoint the toast
watches instead. The poll belongs to the load call rather than to a page, so the
row still settles when the user navigates away mid-load; a load that never
started settles from the POST, so exactly one of the two paths ends the notice.
An unreadable progress read is not treated as terminal, since a restarting
backend would otherwise drop a live row, and an hour-long deadline covers a
backend that never answers again.
* Studio: say what the eject read bound does and does not cover
* Studio: bound each background progress read, and drop pending loads on disable
Two follow-ups on the loading rows.
The hour-long deadline only bounded the settle loop if every turn of it ended.
A load-progress request that accepted the connection and never answered parked
the await forever, so the deadline was never tested again and the row stayed
loading for the life of the tab. Each read now runs under its own AbortController
timer, and both progress endpoints take a signal so the request is really
dropped, not just ignored. A plain controller rather than AbortSignal.timeout,
which the older WebKitGTK builds Tauri embeds do not have.
Nothing listens for the terminal event while the indicator is disabled, so a
load in flight when the pref went off left a pending entry that came back on
re-enable as a row no poll could retire: withPendingLoads only yields to a
status row for the same runtime, and a failed or since-unloaded load has none.
The pending map is now emptied on the disable transition, adjusted during render
rather than from an effect so the stale rows never reach the DOM.
* Studio: re-read the chat status when another runtime loads
The header kept its tick on an evicted model because nothing ever told the
chat page the model had gone. syncInferenceStatusToStore, the only thing that
updates residency, runs on mount and from refreshModelLists when the model
lists change. It is not on a timer. So an image load evicting the chat model
was never observed here: residentCheckpoint stayed undefined, which reads as
loaded, and the previous two commits could not take effect.
Confirmed against the running server rather than by reading: /api/inference/status
reports active_model null while the header still showed the model ticked, and
/api/models/list, which only that sync calls, was absent from the log for the
whole period after the eviction.
The chat runtime now re-reads whenever another runtime finishes a load, using
the lifecycle event added for the indicator. Loras are skipped: only residency
is in question.
* Studio: settle a cancelled load, and show a replacement next to what it replaces
Two faults in the loading rows, both in the window where a load is in flight.
A cancelled or evicted load leaves load-progress reporting null: all three
engines return that only for "nothing loading and nothing loaded", and unload
bumps the load token and drops the loading marker, so the phase never changes
again. Treating null as non-terminal was wrong -- begin_load records its loading
state before the POST answers, so a read before the first byte moves already
says downloading, and null after that means the load ended with nothing to show.
Ejecting a model whose replacement was still downloading therefore replaced the
row with a spinner that had no eject on it and stood for the full hour deadline.
The Images page's own poll has always treated null as terminal, "else this loop
spins forever". An unreadable read stays distinct from it, as undefined.
The other half is why that eject was offered at all. Images and video keep the
old pipeline resident while the replacement downloads, freeing it only at the
commit, so status reports the old model for the whole pull. Suppressing the
announcement on the runtime alone hid the incoming model for minutes, showing
only the one being replaced. A status row now wins only when it describes the
same load; a row that is itself loading still wins, which is what keeps chat and
dictation from announcing a load twice when the backend spells the name
differently.
* Studio: retire a settled load row only once a read replaces it
The row vanished at random. On the settle event the optimistic row was dropped
immediately and a read was asked for, but that read is a round trip away and
can be queued behind one already in flight. For that gap the card had one row
fewer, and nothing at all when it was the only one, so it blinked out and came
back a moment later.
The row is now retired by the read that supersedes it rather than ahead of it,
so the card hands over from the announced row to the status row with no gap
either way. A read that fails still retires it, from the same finally, so a
row cannot be left spinning.
* Studio: initialise the loaded models preference before the indicator
There is a real cycle here: the barrel evaluates the indicator, the indicator
imports the settings barrel, that eagerly evaluates SettingsDialog and so
general-tab, and general-tab's reset list dereferences
LOADED_MODELS_PREFERENCE_KEYS back through the barrel it is still inside.
It happens to work today only because app.tsx reaches the settings barrel first,
which finishes the preference module before general-tab's body runs. Entering
the loaded models barrel first is enough to break it: under Vite dev, which
serves native ESM and so has no bundler to reorder the graph, that throws
"Cannot access 'LOADED_MODELS_PREFERENCE_KEYS' before initialization" and the
app never starts.
Exporting the preference module first evaluates the constant before the
indicator is touched, so either entry order is safe. Pinned by a test, since a
bundler can hide this.
* Studio: gate the picker and hub "Loaded" marks on residency too
The header tick was one of three places claiming a model was loaded, and
all three read the picker selection. An image or video load evicts the
chat model, which leaves the selection alone, so the dropdown's green
"Loaded" badge and the Model hub cards went on marking a model the
backend had already released.
Both now use the same rule as the header: resident, not merely picked.
An unread status stays as it was, so nothing flashes on startup, and an
external selection is untouched since it holds no local weights.
* Studio: drag both floating panels without a render per frame
Dragging the loaded models card or the Live monitor was heavy. Three
things, all per pointermove: each move set React state, so a trackpad
reporting faster than the display refreshed rendered frames nobody saw;
the move was written to left/top, which is the layout path, and both
panels are expensive to lay out again (a wide blurred shadow on one, a
backdrop filter on the other); and the card's reclamp effect was keyed
on the position it was updating, so every frame disconnected its
ResizeObserver and built a new one, each rebuild forcing a synchronous
layout.
A drag now records the offset, asks for one animation frame, and moves
the node with a translate3d, so it costs no render at all. Release hands
the offset back to left/top, written to the node as well as to state so
it cannot flash at the spot it started from, and folds in a frame still
owed rather than landing behind a flick.
The monitor also published its box to the shared frame store on every
frame, re-rendering each overlay subscribed to it. That now settles on
release, so the stack moves aside once instead of chasing the drag.
* Studio: drop the chat pick when the server evicts the model
Loading an image model evicts the chat one, and the picker went on
naming it. The last few rounds only dimmed the marks that said "loaded",
which was the wrong end of it: the name on its own reads as "this is my
model", and sending to it comes back a bare 400.
An eviction now clears the pick, so the picker drops to "Select model",
which is already what a server-side unload does. Guarded on the model
having been known resident and on no load being in flight, since a load
also reports no active model while it runs and must not wipe the pick
the user just made.
* Studio: time the load row's giving-up from the last healthy read, not the start
An absolute hour deadline retired the row on a download that was still visibly
progressing: a large video checkpoint on a slow link legitimately runs longer
than that, and every progress read still said downloading. The window now
measures a stall, restarting on each healthy read, so only a sustained run of
unreadable polls gives up and a load that keeps reporting progress is never
abandoned.
Also drop the drag sentinel when the handle that started the drag has no click
to consume it. The expanded card's grip is not a button, so after dragging by it
the flag stayed set, and the collapsed pill's next click read a drag that was
not its own and refused to expand. Cleared on both end paths: pointerup and the
buttons === 0 bail for a release the window never saw.
* Studio: let the loaded models card be closed, and reopen it on the next load
Adds an X beside the collapse chevron, and swaps the card's brain badge
for the sparkle.
Closing is its own flag, not the Settings toggle. They read as the same
thing but are not: closing means "not now", so the next model load
brings the card back, while the toggle means "stop showing me this" and
no load may override it. Reopening happens on the start of a load, so
the card is up for the same stretch as the toast, and setting the flag
to what it already holds is inert, since every load start writes it.
Kept in storage rather than in memory, like the collapsed state, so a
reload does not undo a close.
* Studio: eject with the eject glyph, and badge the card with the single sparkle
A row's X released the model's weights while the header's X only closed
the card, so one glyph stood for two unrelated acts. The row now uses
Remove circle, which is already the model picker's own eject shortcut,
and the header keeps the X the Live monitor closes with.
The badge was Sparkles, which is the two-star glyph. Sparkle is a
separate icon and the free set shipped in 4.1.1 predates it, so it is
defined in lib/ beside the chevrons rather than moving every other glyph
in the app to a newer icon set for one shape. The path is the published
stroke-rounded outline with its fixed stroke swapped for currentColor.
* Studio: drop the drag sentinel option, which guarded a state that cannot happen
I added this on a review note and the note was wrong. Every drag handle wires
onPointerDown to startDrag, and startDrag zeroes movedRef; pointerdown fires
before click, so the pill always clears the sentinel before its own click reads
it. A drag by the expanded grip cannot leave a stale flag for the pill to trip
over, and the clickFollows option was guarding a state the code cannot reach.
Checked rather than reasoned: with the option removed, dragging by the grip,
collapsing, and clicking the pill once reopens the card, on all three engines.
That sequence is now a browser check, and it asserts the collapse really
happened first so it cannot pass vacuously. The unit tests assert the invariant
that actually holds the line, which is that every handle goes through startDrag.
* Studio: keep recording loads while the card is closed, so one can reopen it
Closing the card is meant to mean "not now", with the next load bringing it
back. The dismissal was cleared correctly, from the indicator's own
subscription, but the rows come from useLoadedModels and that subscription
early-returned while the card was hidden. So the loading: true event was never
recorded, and the card reopened onto nothing.
Chat and dictation limped: the 5s poll synthesises their loading rows from
status.loading and the per-engine flag. Images and video have no such fallback,
so with nothing else resident the card did not come back at all until the load
finished, which on a large download is minutes. Reproduced in a real browser
before fixing, and the reopened card now reads "Image . Loading" for the model
that reopened it.
Recording is now separate from showing: only the Settings toggle stops it, since
that is the one that means "stop telling me", while a closed card or a route
that hides the card keeps listening. The pending map is still emptied when
recording stops, so a load missed while the preference was off cannot come back
as a row no poll can retire.
* Studio: do not read an unreadable dictation status as an empty one
The dictation unload answers {loaded_model: null} whatever it did, and the
backend serves gguf from the transformers engine when whisper-server is absent,
so the eject re-reads the status to find out what actually happened. But
readSttStatus returns null on any non-2xx as well as for an empty runtime, and
the re-read treated the two the same: a transient 404 or 500 on the confirming
read toasted "Ejected" and dropped a row whose model was still holding memory.
That is the exact case the re-read exists to catch.
An unconfirmed unload is now its own outcome rather than being collapsed into
success or failure, since neither is true, and it warns instead of claiming the
memory was freed. Images and video already raise here, since their unload
responses go through parseJson; only dictation swallowed it.
* Studio: re-read chat residency when a media load takes the GPU, not when it ends
The arbiter evicts chat inside the image or video load POST, before the
background download starts, but the chat runtime only re-read its status when
that load settled. Since the settle waits for the whole download, the picker and
the header went on naming the evicted model for its entire duration.
Measured against a live backend rather than inferred: with Qwen3-0.6B-GGUF
resident, /api/inference/status reported active_model null 1.8s after the image
load POST returned, and a send to the model the picker still offered answered
400 "No model loaded. Call POST /inference/load first."
The load is now announced a second time once the POST returns, which is the
instant the eviction has committed; announcing only before it would have had
listeners re-read a status that was still correct. Dictation is excluded, since
it is a sidecar and takes no GPU ownership.
Also settle the media page's own busy state when an eject cancels its load. The
indicator shows the resident row next to the incoming one during a replacement,
so that eject is reachable; the page's listener then stops the load-progress
poll, which is the only thing that clears busy. Left set it locked the page --
the picker ignores every choice, and Unload is not rendered once the status read
comes back empty -- and both pages stay mounted for the session, so only a
reload recovered.
* Studio: stop the indicator reporting ejects it did not perform
Three of the four eject paths could report a success with no unload behind
it, and the card could overwrite a saved position it was only adapting to
the current window. Found by driving the branch against a real backend in
Chromium, Firefox and WebKit with the four /status endpoints stubbed.
A stale row over an idle runtime said "Ejected X". ejectRuntimeRow folded
verifyResident's "gone" verdict into "ejected", so a row up to one poll old
whose runtime had already released everything reported an unload that never
ran. Images, video and dictation all reach this without anyone doing
anything: the dictation sidecars release themselves when idle. It is now a
distinct alreadyFree outcome, and the toast says the model was no longer
loaded. The row still goes, since its memory is free either way. That also
retires the `&& resident` guard, which could not be false: verifyResident
only answers "replaced" when resident is set.
A cached chat row was unloaded without confirmation. It is the one path with
no scoped re-read, because a cached row is never the active model and so can
never match one, and /unload answers 200 for a name the backend no longer
holds. So the success was the call itself rather than evidence of a release,
and the picker was cleared on it. ejectChatModel now takes an optional
readCached and checks the row against what the runtime still holds, the way
every other path checks its own.
The card overwrote a position saved on a larger screen. The persist ran from
an effect on `position`, and a reclamp changes `position` too, so opening the
app on a laptop wrote the laptop's clamp back to localStorage and going back
to the large monitor left the card where the laptop had put it. Only a landed
drag is stored now; the read path clamps, so nothing needs the clamp kept.
Arabic said the card is in the bottom-left. Nothing sets document.dir and the
stack is anchored with a physical `right-4`, so it is bottom-right there too.
Covered by three cases in the node suite for the cached row, one for the
persist rule, and one in the Playwright suite for the stale row, which runs
on all three engines. Full suite 896 pass, typecheck, i18n parity and build
clean, and the same on a tree merged with current main (1126 pass).
* Studio: keep a row whose runtime could not answer, rather than reading it as gone
A read that fails or times out comes back as null and the describe helpers map
null to no rows, so one bad poll took a loaded model off the card. Against a
remote Studio all four reads can fail on the same blip, and the whole card went
while every runtime stayed resident.
An unreadable source now keeps what it last showed. A readable one is still
replaced outright, including by an empty answer, so an unload clears the row as
before -- that is the distinction that matters, and both halves are checked in
the browser suite: a sustained 503 on the chat status keeps the row, and a
readable empty status still retires it. Reverting the change fails the first and
leaves the second passing.
* Studio: sequence the chat refreshes, and reopen a closed card for a silent load
Announcing a media load twice put two chat refreshes in flight inside the load
POST's own duration, measured at 1.8s against a live backend. They read the
status either side of the eviction and answer in whatever order the network
gives, so the older one could land last and re-pin the model the newer had just
seen released, leaving chat claiming a model that 400s on send until the load
settled. Every refresh now takes a generation and the newest wins; a superseded
one writes nothing back and reports no failure either, since its answer
describes a moment that has passed.
Separately, the close tooltip promises "Back on the next model load", and that
did not hold for a load started outside the frontend: the OpenAI-compatible API
and auto-switch raise no lifecycle event, and dismissing the card had also
stopped the poll, so nothing was left to notice. The poll now runs while the
card is closed, and a row appearing that was not there on the previous poll
reopens it. The first poll after closing is the baseline rather than a reopen,
or -- since a dismissal survives a reload -- whatever was already resident would
make the card impossible to close at all.
* Studio: keep a settled load's row when the read that should replace it failed
An optimistic row is retired once a status read has replaced it with a real one.
But readLoadedModels is handed the polled rows only, and an optimistic row lives
in the pending map, so a read that could not see that source has nothing to
preserve: retiring anyway took the row for a model that had just finished
loading off the card, on the strength of a request that failed.
The read now reports which sources it could say nothing about. Those stay
settled, so the next readable poll retires them instead, and a read that failed
outright is treated as evidence about none of them. A source that did answer is
retired exactly as before, including when it answers empty, or the row would
never go at all.
* Studio: stop writing off an eject target that was only demoted to the cache
Eject an active row that was replaced inside the card's five-second poll window
and nothing was released. The standard backend does not evict on a switch:
load_model only moves active_model_name and leaves the previous model in
backend.models (core/inference/inference.py:620), which /status still reports
under `loaded` (routes/inference.py:8112). So a switch from another tab, the
OpenAI-compatible API or the CLI demotes A to a cached entry that is still
holding its weights, the scoped read cannot match it, and the click reported
"A is no longer loaded. B took its place and was left alone" having unloaded
nothing. With the replacement since gone it read as alreadyFree and dropped the
row as well.
The branch now asks what the runtime is really holding before writing the row
off, reusing the readCached the confirming read already had, so the demoted
model is unloaded by name and confirmed like any other. A target that is
genuinely gone still reports replaced and unloads nothing, and with readCached
omitted the behaviour is unchanged. GGUF is unaffected: that branch reports
loaded as the single active id.
Three cases in the node suite: a row replaced while still cached, a row
replaced and really gone, and an idle runtime still holding the row. The first
and third fail with the source reverted.
* Studio: keep the indicator off the login page, and surface what a GGUF load left behind
Two from the latest round, both real.
Recording polled four protected endpoints on /login. Widening the recording
flag past `enabled` was right for dismissal, so a closed card still hears the
load that reopens it, but it went one step too far: `canShowIndicator` also
carries the auth gate, and gating recording on the Settings toggle alone left
the poll running before there was a session. Each tick then sent four requests
that 401 and drove authFetch's refresh-then-redirect ladder, every five
seconds, for as long as the login page was open. Reachability is now hoisted
and shared, so tracking survives a dismissal but not a route with no session.
A model cached behind a GGUF was invisible and could not be released. The GGUF
load unloads only the ACTIVE Unsloth model (routes/inference.py:6603-6610), so
a Transformers model cached behind it keeps its weights, but the GGUF status
branch reported `loaded` as the single active id. So after loading A, then B,
then a GGUF, A held VRAM that nothing surfaced and no client could free. That
branch now also reports what the registry still holds, deduped against the
GGUF's own id and peeking rather than constructing the orchestrator. The unload
route already reaches a cached model by name through
_resident_standard_model_name, so the rows this adds are ejectable as they
stand.
Three backend cases and one frontend case, each failing with its source
reverted. 919 node tests and 31 backend tests pass; typecheck, i18n parity and
build clean.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: lift the corner stack above the chat composer, which the card was covering
The loaded models card is the first overlay in the bottom-right stack that is
persistent rather than transient, and the chat composer docks to the bottom of
that same column once a thread has turns. The card landed on top of the Send
button and swallowed the click: Playwright resolved the button, found it
visible, enabled and stable, then retried for 60s while
"unsloth/gemma-3-270m-it-GGUF ... subtree intercepts pointer events". Caught by
the chat UI suite on both ubuntu-latest and macos-14.
The stack already knows how to keep clear of something: the Live monitor
publishes its box and stackGeometry lifts the stack above it. That store held
one box, from one publisher, so the composer had nowhere to say where it is.
It now keeps a box per publisher and dodges their union, which is also the
right answer while the monitor is mid-exit and two panels are briefly
published at once.
The composer publishes through a small usePublishedFrame hook, re-measuring on
resize and through a ResizeObserver because it grows with its input and moves
from centred to docked without a resize event. A hidden composer measures 0x0
and is withdrawn rather than published, which would otherwise pull the union
to the top-left and pin the stack there.
Verified against a live backend at 1280x900, 1280x720 and 1440x900 with the
composer docked: the element under the Send button's centre is the composer,
and reverting the publish alone puts the overlay stack back there at all
three. 1289 node tests pass, typecheck, i18n parity and build clean.
* Studio: fold the stack's obstacles one at a time, not as their bounding box
Follow-up to the composer fix. Publishing a second box made the store hold two,
and reading their union as one obstacle was wrong: a tall monitor and the wide
docked composer share almost no area, so the rectangle around the pair covers
most of the viewport. stackBottomInset then lifted the stack to its clamp near
the top of the screen and put it back over the monitor it was dodging. The chat
UI suite caught it on Windows and macOS as the card swallowing the monitor's
Close button, at the step that drags the monitor up the column first, which is
exactly when the union is most unlike its parts.
stackGeometry now folds per box: each asks for the lift it needs, the stack
takes the largest, and the height is the shortest any of them allows. The
single-box maths is untouched, so its existing cases still pin it. The store
keeps the union as a summary only, and says so.
Three cases added: the fold against a monitor parked high, which is the case
that diverges, plus an empty list and a single box matching the old calls.
1295 node tests pass, typecheck and build clean, and the docked-composer probe
still finds the composer under the Send button at three viewports.
* Studio: let only the newest status read write on the media pages
The Images and Video pages hold their own status and re-read it on tab
activation and on their own actions, never on a timer. Two reads are therefore
in flight across an eject: the activation read that saw the pipeline loaded,
and the post-eject read that saw it gone. Responses have no order, so the older
one could land last and put the loaded status back, leaving the page offering
to generate against a runtime that was already free, with no poll coming to
correct it. Both pages had it, identically.
Every read now takes a ticket before it awaits, and only the newest may write.
All three writers on each page go through the same gate, not just the refresh:
the load-progress read and the unload response could overwrite a newer answer
just as easily.
Also drops the merged rectangle the frame store still exposed. Nothing read it
after the fold landed, but it was the shape the review picked up on, and it
merged obstacles that do not touch: the rectangle around a left-hand monitor
and a bottom-right composer spans the empty space between them. The store keeps
the boxes apart and says why; stackGeometry already folds them one at a time.
Four cases pin the sequencing, all four failing with the pages reverted. 1299
node tests pass, typecheck, i18n parity and build clean.
---------
Co-authored-by: shimmyshimmer <danielhanchen@gmail.com>
Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
232 lines
9.7 KiB
TypeScript
232 lines
9.7 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
// The real comparator, not a stand-in: scoping the eject to one row is the whole
|
|
// behaviour, and a permissive fake would prove nothing about it.
|
|
import { modelIdsMatch } from "../src/features/hub/lib/model-identity.ts";
|
|
import {
|
|
type ResidentChatModel,
|
|
ejectChatModel,
|
|
} from "../src/features/loaded-models/eject-chat-model.ts";
|
|
|
|
function resident(checkpoint: string): ResidentChatModel {
|
|
return { checkpoint, aliases: [checkpoint] };
|
|
}
|
|
|
|
/** A backend whose resident model follows `timeline`, one entry per status read.
|
|
* An entry after the first is what an API auto-switch left there. */
|
|
function backend(
|
|
timeline: (ResidentChatModel | null)[],
|
|
cachedRow = false,
|
|
/** What the runtime still holds after a cached row's unload. */
|
|
cachedAfter: string[] | null = null,
|
|
) {
|
|
const unloaded: string[] = [];
|
|
let read = 0;
|
|
return {
|
|
unloaded,
|
|
deps: {
|
|
readResident: async () => timeline[Math.min(read++, timeline.length - 1)],
|
|
unload: async (modelPath: string) => {
|
|
unloaded.push(modelPath);
|
|
},
|
|
matches: modelIdsMatch,
|
|
cachedRow,
|
|
...(cachedAfter === null ? {} : { readCached: async () => cachedAfter }),
|
|
},
|
|
};
|
|
}
|
|
|
|
test("the row's model is unloaded and reported free", async () => {
|
|
const { unloaded, deps } = backend([resident("unsloth/Qwen3-4B"), null]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(unloaded, ["unsloth/Qwen3-4B"]);
|
|
assert.deepEqual(result.unloadedAliases, ["unsloth/Qwen3-4B"]);
|
|
assert.equal(result.stillResident, null);
|
|
});
|
|
|
|
// The reason this is scoped: the row is up to one poll old, so an auto-switch
|
|
// can land between the poll and the click.
|
|
test("a model that replaced the row's before the click is left alone", async () => {
|
|
const { unloaded, deps } = backend([resident("unsloth/Llama-3.2-3B")]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.ok(
|
|
!unloaded.includes("unsloth/Llama-3.2-3B"),
|
|
"the model nobody clicked must survive",
|
|
);
|
|
// Nothing at all is unloaded. /unload naming a model the backend does not
|
|
// hold answers 200 "unloaded", so firing it would report an eject that never
|
|
// happened and clear the picker off the back of it.
|
|
assert.deepEqual(unloaded, []);
|
|
assert.deepEqual(result.unloadedAliases, []);
|
|
assert.equal(result.stillResident, null);
|
|
assert.equal(
|
|
result.replacedBy,
|
|
"unsloth/Llama-3.2-3B",
|
|
"the caller needs the replacement's name to say what took its place",
|
|
);
|
|
});
|
|
|
|
test("an idle runtime reports the row already gone, not a fresh eject", async () => {
|
|
const { unloaded, deps } = backend([null]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(unloaded, [], "nothing is resident, so nothing to unload");
|
|
assert.deepEqual(result.unloadedAliases, []);
|
|
assert.equal(result.stillResident, null);
|
|
// Null rather than a name: the runtime holds nothing, so there is no
|
|
// replacement to name and the row is simply stale.
|
|
assert.equal(result.replacedBy, null);
|
|
});
|
|
|
|
test("a switch landing mid-eject is not chased", async () => {
|
|
const { unloaded, deps } = backend([
|
|
resident("unsloth/Qwen3-4B"),
|
|
resident("unsloth/Llama-3.2-3B"),
|
|
]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(unloaded, ["unsloth/Qwen3-4B"]);
|
|
assert.equal(result.stillResident, null);
|
|
});
|
|
|
|
test("a target that survives its own unload is reported still resident", async () => {
|
|
const { unloaded, deps } = backend([resident("unsloth/Qwen3-4B")]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
// Two passes, then it gives up and names it rather than looping.
|
|
assert.deepEqual(unloaded, ["unsloth/Qwen3-4B", "unsloth/Qwen3-4B"]);
|
|
assert.equal(result.stillResident, "unsloth/Qwen3-4B");
|
|
// Both are set together here, so the caller must key the picker clear on
|
|
// stillResident: the aliases alone would empty it while the model still runs.
|
|
assert.ok(result.unloadedAliases.length > 0);
|
|
});
|
|
|
|
// A row the backend kept past the active model is never what a status read
|
|
// reports, so it is the one case that has to be named directly.
|
|
test("a cached row with nothing resident is still unloaded by name", async () => {
|
|
const { unloaded, deps } = backend([null], true);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(unloaded, ["unsloth/Qwen3-4B"]);
|
|
assert.deepEqual(result.unloadedAliases, ["unsloth/Qwen3-4B"]);
|
|
assert.equal(result.replacedBy, null);
|
|
});
|
|
|
|
test("a cached row is unloaded even while another model is active", async () => {
|
|
const { unloaded, deps } = backend([resident("unsloth/Llama-3.2-3B")], true);
|
|
await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(
|
|
unloaded,
|
|
["unsloth/Qwen3-4B"],
|
|
"the cached copy goes, the active model stays",
|
|
);
|
|
});
|
|
|
|
// /unload answers 200 for a name the backend no longer holds, and the cached
|
|
// row is the one path with no scoped read to catch that, so the reported
|
|
// success was the call itself rather than any evidence of a release.
|
|
test("a cached row the backend kept is reported still resident", async () => {
|
|
const { unloaded, deps } = backend([null], true, ["unsloth/Qwen3-4B"]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(unloaded, ["unsloth/Qwen3-4B"], "the unload was attempted");
|
|
assert.equal(result.stillResident, "unsloth/Qwen3-4B");
|
|
assert.deepEqual(result.unloadedAliases, [], "nothing to clear the picker on");
|
|
});
|
|
|
|
test("a cached row the backend released is reported ejected", async () => {
|
|
const { deps } = backend([null], true, ["unsloth/Llama-3.2-3B"]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.equal(result.stillResident, null);
|
|
assert.deepEqual(result.unloadedAliases, ["unsloth/Qwen3-4B"]);
|
|
});
|
|
|
|
// Both the row and the confirmation come from the same `loaded` list, so the
|
|
// names line up by construction; the comparator is there for the day they do not.
|
|
test("a backend that cannot be re-read leaves the old reading alone", async () => {
|
|
const { unloaded, deps } = backend([null], true);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(unloaded, ["unsloth/Qwen3-4B"]);
|
|
assert.deepEqual(result.unloadedAliases, ["unsloth/Qwen3-4B"]);
|
|
assert.equal(result.stillResident, null);
|
|
});
|
|
|
|
test("the load path and the advertised repo id are the same row", async () => {
|
|
const loadPath = "/models/hub/models--unsloth--Qwen3-4B/snapshots/abc";
|
|
const { unloaded, deps } = backend([
|
|
{ checkpoint: loadPath, aliases: [loadPath, "unsloth/Qwen3-4B"] },
|
|
null,
|
|
]);
|
|
await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(unloaded, [loadPath], "matched by identity, not by string");
|
|
});
|
|
|
|
/** A backend whose `loaded` list follows `timeline`, one entry per cached read,
|
|
* with `active` resident throughout. What an eject sees when a load landed
|
|
* between the card's last poll and the click. */
|
|
function replacedBackend(
|
|
active: ResidentChatModel | null,
|
|
timeline: string[][],
|
|
) {
|
|
const unloaded: string[] = [];
|
|
let read = 0;
|
|
return {
|
|
unloaded,
|
|
deps: {
|
|
readResident: async () => active,
|
|
unload: async (modelPath: string) => {
|
|
unloaded.push(modelPath);
|
|
},
|
|
matches: modelIdsMatch,
|
|
readCached: async () => timeline[Math.min(read++, timeline.length - 1)],
|
|
},
|
|
};
|
|
}
|
|
|
|
// The row is up to one poll old, and a replacement does not evict what it
|
|
// replaced: the standard backend moves active_model_name and leaves the
|
|
// previous model in its registry, which /status goes on reporting under
|
|
// `loaded`. So another model being active is not evidence the row's is gone,
|
|
// and taking it as such freed nothing while telling the user it had.
|
|
test("a row replaced while still cached is unloaded, not written off", async () => {
|
|
const { unloaded, deps } = replacedBackend(resident("unsloth/Llama-3.2-3B"), [
|
|
["unsloth/Qwen3-4B", "unsloth/Llama-3.2-3B"],
|
|
["unsloth/Llama-3.2-3B"],
|
|
]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(
|
|
unloaded,
|
|
["unsloth/Qwen3-4B"],
|
|
"the memory the click asked for is the memory released",
|
|
);
|
|
assert.deepEqual(result.unloadedAliases, ["unsloth/Qwen3-4B"]);
|
|
assert.equal(result.stillResident, null);
|
|
assert.equal(
|
|
result.replacedBy,
|
|
null,
|
|
"an eject that ran is not a row that had already gone",
|
|
);
|
|
});
|
|
|
|
// The other half of the same read: a runtime that really did let go still
|
|
// reports the replacement and unloads nothing, which is the whole point of
|
|
// scoping the eject to one row.
|
|
test("a row replaced and really gone is still left alone", async () => {
|
|
const { unloaded, deps } = replacedBackend(resident("unsloth/Llama-3.2-3B"), [
|
|
["unsloth/Llama-3.2-3B"],
|
|
]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(unloaded, [], "nothing but the row's own model may go");
|
|
assert.deepEqual(result.unloadedAliases, []);
|
|
assert.equal(result.replacedBy, "unsloth/Llama-3.2-3B");
|
|
});
|
|
|
|
// Same window, with the replacement since unloaded: nothing is active, but the
|
|
// row's model is still held, so "already free" would have been wrong too.
|
|
test("an idle runtime still holding the row releases it", async () => {
|
|
const { unloaded, deps } = replacedBackend(null, [["unsloth/Qwen3-4B"], []]);
|
|
const result = await ejectChatModel("unsloth/Qwen3-4B", deps);
|
|
assert.deepEqual(unloaded, ["unsloth/Qwen3-4B"]);
|
|
assert.deepEqual(result.unloadedAliases, ["unsloth/Qwen3-4B"]);
|
|
assert.equal(result.stillResident, null);
|
|
assert.equal(result.replacedBy, null);
|
|
});
|