Commit graph

1469 commits

Author SHA1 Message Date
shimmyshimmer
fb4d8a8460 default new installs to HTTPS downloads, and put the choice in Settings
The download transport preference defaulted to "auto", which resolves to Xet on
any machine whose hf_xet looks healthy, so in practice everyone downloaded over
Xet without ever choosing it. New installs now download over HTTPS: it resumes a
cancelled or dropped transfer, and it needs nothing but ordinary TLS.

Installs from before this default keep Auto, seeded once from positive evidence
of prior use (a row the user wrote, or a manifest from an earlier download) and
persisted, so updating changes nobody's downloads.

Settings > General gains a Downloads section with an HTTPS / Xet / Auto control,
the difference between the two spelled out, the reason Xet is unavailable when
hf_xet is missing, and what Auto resolves to on this machine.

use_xet defaulted to True in the download schemas, so a scripted caller that
stated no transport got Xet whatever the preference said. It is optional now,
and a request stating neither field follows the install's setting.
2026-08-20 04:02:53 -07:00
Daniel Han
7266f1b29b
Stop a finished save's ingest watcher answering the next test's stub (#9200)
Reduced to the one fix of the original three that is still needed. The other two
landed by other routes while this sat: the orphan-scan flake was fixed by #9280,
and the extensionless `./mmproj-fallback` import is already `.ts` on main. Both
are verified identical to main here rather than assumed.

On #9280 taking a different approach: it pins `_pid_parent_is_alive` to a
constant for the whole test, where this PR stubbed it only for the invented PID
and delegated every other PID to the real implementation. That is a real
difference in coverage, so I checked whether main lost anything before dropping
mine. It did not: `test_reap_recorded_pid_spares_live_server` in
test_llama_cpp_wait_for_vram_settle.py exercises the ownership gate for real,
against a process the test genuinely owns. So the gate is still covered and
#9280's version stands.

What is left, and why it is still needed
------------------------------------------------------------------------
`saveMarkdownAsProjectSource` fires `watchIngestion` without awaiting it, and
that watcher polls for up to 300 seconds. Watchers from earlier tests therefore
outlive them and keep calling the shared fetch stub, which by then belongs to
whichever test is running now. The stub answered any `/jobs/` URL and the uploads
shared a filename, so a stale watcher emitted a toast byte-identical to the one
the running test was waiting on, under a different project id.

The test then waited on the toast and read the announce count without polling.
The watcher toasts and announces with nothing awaited between them
(save-markdown-source.ts, `toast.error(...)` then
`announceProjectSourcesUpdated(...)`), so a tick landing in that window saw 1
instead of 2.

Each job handler now answers only its own job id and 404s anything else, so a
stale watcher dies quietly instead of borrowing the running test's answers. The
save under test gets its own filename, and the test polls on the announce, which
the watcher does last, then asserts the toast synchronously.

Verified
------------------------------------------------------------------------
Gap sweep, inserting a 150 ms delay into the source between the toast and the
announce to widen the window the race needs: main's version of the test fails 6
of 6 runs, this version passes 6 of 6. Both pass 6 of 6 on an idle box with no
delay, which is why this reads as an intermittent CI failure rather than a
reproducible one.

Mutation-tested: deleting `announceProjectSourcesUpdated` from the watcher fails
this test (9 passed, 1 failed), so it still proves what it did before rather than
having been loosened into passing.

Full frontend suite: 4087 passed, 0 failed.
2026-08-20 04:02:08 -07:00
Daniel Han
6b2445bbb3
Drop the HunyuanImage GGUF the Hub no longer serves (#9369)
`Model catalog network check` is red on main:

    ::error::QuantStack/HunyuanImage-2.1-GGUF: HTTP 401 from
    https://huggingface.co/api/models/QuantStack/HunyuanImage-2.1-GGUF
    -- the repo is missing, renamed or private
    model-catalog network check: 1 problem(s)

The check is right and the catalog is wrong, which is what that workflow says a
red run means: "A red run here means the catalog needs editing, not a re-run".

Confirmed against the Hub directly rather than trusting the annotation. The repo
answers 401 anonymously and 404 to an authenticated request, so it is gone rather
than gated or rate-limited. QuantStack itself is fine and still ships its other
GGUF repos (Wan2.2-I2V-A14B, Qwen-Image-Edit-2509, FLUX.1-Kontext-dev among
them), so this is one model withdrawn, not a publisher disappearing.

An entry the Hub cannot serve is worse than no entry: it renders as a one-click
download that fails partway through, which is exactly the case this workflow was
built to find.

No replacement, deliberately
------------------------------------------------------------------------
I looked before removing. unsloth has HunyuanImage-2.1-FP8, which ships no GGUF.
The third-party HunyuanImage GGUF repos on the Hub are a different lineage --
calcuis/hunyuanimage-gguf is 89 files of "lite" and "v2.0" variants, svjack's is
five split files with 39 downloads. Substituting one changes which weights users
download, and that is a product decision rather than a CI fix, so I have not made
it. Say the word and I will wire whichever you prefer.

What this costs
------------------------------------------------------------------------
The quant ladder, not the model. At 24 GB the group's budget is 61.6 GB and the
bf16 is 50 GB, so HunyuanImage 2.1 stays visible and routes to bf16 instead of
being hidden. The check file now asserts that at both ends, so a future change
that drops the group entirely cannot pass quietly; the old assertion pinned the
GGUF as the 24 GB pick and would simply have been deleted with it.

Left alone on purpose
------------------------------------------------------------------------
tests/test_diffusion_more_families.py still parametrises the dead repo id.
detect_family is pure string parsing with no network in it, and someone who
already downloaded that repo still needs their local copy to resolve to the
hunyuanimage-2.1 family. Removing it would drop coverage of the filename-alias
path (QuantStack drops the dash) for no gain.

The three remaining `canonicalId ... is not a real repo` warnings are unrelated
and correctly advisory: those ids are grouping keys that appear in no artifact
list, so by construction they cannot reach a load. The check computes them as
exactly the canonicalIds absent from artifactsByRepo, so that is structural, not
a promise.

Verified
------------------------------------------------------------------------
`npm run catalog:check --network`: "every declared repo, file and gated flag
agrees with the Hub", where it previously reported 1 problem.
Offline `npm run catalog:check`: all assertions passed.
Full frontend suite: 4080 passed, 0 failed.
2026-08-20 03:04:51 -07:00
Daniel Han
f68cbac1f0
Drain the sampling suites on the store's write chains, not on node's loader (#9367)
#9332 fixed these suites by waiting on two observables: the mocked timers, which
it counts exactly, and the module loader, which it raced by issuing an import of
its own each round. The second one works and is still the wrong thing to assert.

The loader probe rests on an assumption about node internals that I recorded in
the comment and never verified: that the hooks thread serves requests in order
over one port, so a reply to a request issued after the store's cannot arrive
first. If that ever stops holding it fails as a stale read, which is precisely
the failure mode #9332 existed to abolish -- a wait that comes up short reports
"chat A minP: owed 0, shows 0.01" and sends the next reader into the store.

The store already tracks the thing worth waiting on. threadSettingsWriteChains
holds the live promise chain per thread, and awaitThreadScopedSettingsWrite
already awaits one of them by id. What a drain needs is all of the started ones,
without knowing which chats the store decided to write, so this adds
awaitStartedThreadScopedSettingsWrites.

It is deliberately not a flush. A debounce that has not fired yet is left alone,
so a caller cannot use it to make a write happen earlier than the store would
have; that would let a test pass against a store that never scheduled the write
at all. It is bounded at 20 passes rather than looping until the map empties, so
a write that keeps rescheduling itself surfaces as a failed assertion instead of
a hang. It repeats rather than awaiting one snapshot because a chain that settles
can leave a newer one behind it for the same chat.

drainMockedTimers now takes that as a caller-supplied `barrier` instead of
carrying its own loader probe. The timer counting is unchanged, and so is the
exhaustion throw, which is the property that keeps an under-drain from ever again
looking like a lost edit.

The approach is the one from #9352, which I opened against the same bug in
parallel and is closed in favour of this. #9332's mechanism landed first and
turned Windows green; this replaces the part of it that was a guess about the
runtime with a measurement of the subject.

Verified
------------------------------------------------------------------------
Both suites twice on v24.14.0 and twice on v22.23.2, the version setup-node 22
resolves to: compat 16 passed, simulation 18 passed, every run. Full frontend
suite 4080 passed, 0 failed.

Mutation-tested on v22.23.2, which is the only runtime where this is observable:
disabling the barrier and leaving the timer counting in place gives 8 failed, 10
passed. So the barrier is carrying the load, not decorating a wait that already
worked.
2026-08-20 02:56:15 -07:00
oobabooga
4c5423a36a
Studio: stop docked composer from squeezing download panel (#9334)
* Studio: stop docked composer from squeezing download panel

* Floor the overlay rail card by card

* Measure the rail's cards off the layout box

* Tighten the new comments

* Drop out-of-flow cards from the rail's measurements
2026-08-20 11:48:45 +02:00
Daniel Han
1c3dde199b
Drain the sampling debounce long enough for the node CI actually runs (#9332)
* Stop Frontend CI installing Chromium's system libraries through apt every run

`playwright install --with-deps chromium` runs its own `apt-get update`
inside itself, so it bypassed everything CI has learned about apt: the shared
retry helper's 20s transfer cap, APT_ACQUIRE_RETRIES: '0', and the archive
cache. The job failed 3 of 8 runs on main.

Job 96072994354 (main, 2026-08-19): 9 packages, 21.1 MB, and
`fonts-wqy-zenhei [7472 kB]` alone took 5m50s off azure.archive.ubuntu.com.
Both 420s attempts died mid-download. That is the same mirror and the same
package that took the webkit shards down in #9289. Attempt 2 logged
"Need to get 8833 kB/21.1 MB", so apt does resume partials across attempts and
still could not finish.

Split the way studio-ui-smoke.yml splits it: download the engine, launch it to
find out whether the system libraries are actually missing, and run
`install-deps` only if they are. ubuntu-latest is a browser-testing image and
usually ships them, so the common path now runs no apt at all.

The browser and apt-archive cache keys are deliberately identical to the
chromium-only shards in studio-ui-smoke.yml (engine token `c`): same image,
same Playwright version, same single engine, so the entry is shared rather than
duplicated against a budget measured at 99.3% full.

The step's authorised worst case doubles with the second helper call, to
2 x (2 x 420s + 125s) = 1930s, so its timeout goes 17m -> 33m and stays under
the job's 40m. Both guarded calls are skipped on the common path.

Guard: tests/studio/test_playwright_install_avoids_with_deps.py fails the build
if `--with-deps` returns to any workflow, and is wired into
workflow-trigger-lint, the only job with no paths filter.

* Drain the sampling debounce long enough for the node CI actually runs

Frontend CI has been red on main since #9055, not intermittently: eight
consecutive main runs failed at `Unit tests`, every one on node v22.23.2.

The three suites from #9055 wait for a debounced write with a fixed drain --
three rounds of tick(1000) plus six setImmediate turns -- and then assert. Three
rounds is enough on node 24, which is what a dev box happens to have, and is not
enough on node 22, which `setup-node: 22` resolves to. The same chain drains far
fewer continuations per round there, so the write had not landed when the
assertion ran.

Reproduced by downloading node 22.23.2 and running the suites against both.
Measured on the compat suite:

  rounds   3    10    30    60
  failing  7     5     1     0

The compat suite reported it as a missing value (expected 1.37, actual
undefined). The simulation suite reported it as an ORDERING violation -- "chat A
temperature: owed 0.6, shows 1.37" -- because a scenario whose write has not
landed looks exactly like one that wrote the wrong value, which is why this read
as a feature bug rather than a slow test.

There were three copies of the drain: one per suite plus the shared
thread-sampling-world helper the simulations run through. Fixing only the two
suites left the simulations red, since their scenarios drain through the helper.

The bound is 200, generous rather than tuned to the observed 60, and settle()
now takes an optional `until` predicate: it returns as soon as the caller can
see the work, and throws naming itself if the condition never holds, so the next
slow runtime reports "settle gave up" instead of an assertion on a missing value.

Verified: node 22.23.2 compat 16/16 and simulation 18/18 (both were failing);
node 24 full frontend suite 4060/4060. Only the simulation suite imports the
helper, and no fixed three-round drain remains in tests/.

* Raise the scenario drain to 600: 200 was marginal, and Windows needed more

At 200 the simulations were 0 failing on one run and 1 on the next on the same
machine, and the Windows runner -- slower again -- still had 2 of 120 orderings
short. 600 is 0 failing across three consecutive runs, at 107s against 52s.

Also records the adaptive version that was tried and is wrong here, so it is not
tried again: the rows only change WHEN the write lands, so 'rows have stopped
changing' is precisely the pending state being waited through. Quiescence on
that observable stops early by construction and scored 4 failures where the
fixed bound scored 0.

* Stop the settings smoke asserting a tab count a new page invalidates

Unskipping the browser smokes surfaced this immediately: the blocked-panel run
failed with 'blocking the data panel took the dialog down' while its own report
said dialog: True. Nothing had taken the dialog down. The check was

    if not state["dialog"] or state["nav"] != 12:

and the keyboard-shortcuts page had made the nav 13. A stale constant, reading
as an error-handling regression.

The nav size is now read before the panel is blocked and compared against
itself, which is the invariant that was meant: blocking a panel must not
collapse the dialog, whatever size the dialog is.

The same drift had a quieter half. The smoke's TABS list still had twelve
entries, so keyboard-shortcuts had no browser coverage at all and the smoke
stayed green without it. It is added here, and
tests/studio/test_settings_smoke_covers_every_tab.py pins both directions
against settings-dialog.tsx so the next page cannot go uncovered silently. It
also checks the workflow's PW_CHUNK_FAIL names a tab that exists -- that value
lives in studio-frontend-ci.yml, not in the smoke, and a rename would leave the
run blocking nothing while still reporting PASS.

Wired into workflow-trigger-lint, the only job with no paths filter, because it
reads a workflow. Mutation-tested both ways: dropping the tab from TABS and
restoring the literal count each turn it red.

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

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

* Drain the sampling suites on the loader too, and fail loudly when it gives up

The fixed round count in the previous commit was still a guess, and the Windows
job proved it: the SAME commit at 600 rounds passed one run and failed the next
with "A1: 2 violation(s) across 120 orderings", reporting stale reads as lost
edits. Raising the number again was not the answer.

The pending work has a second half nothing was waiting on. The store's
thread-scoped write ends in `await import("../utils/chat-history-storage")`
(chat-runtime-store.ts:1326 and :1754), and these suites register() a resolver
hook, which routes that import through the hooks thread. Three repeat imports of
an already-loaded module:

  v24.14.0  no hook 1, 1, 1 turns    hook registered 1, 1, 1
  v22.23.2  no hook 1, 1, 1 turns    hook registered 6, 3, 35

That is the whole green-locally / red-on-CI split, and it is why a loaded Windows
runner fails what the same commit passed an hour earlier: the pending work is a
message to another thread, so its cost is scheduling latency, not instructions.
No round count is correct for that.

Counting the mocked timers alone does not cover it either, which is worth
recording since it is the obvious next idea. With the counter installed and 25
consecutive quiet rounds per drain, 150 macrotask turns of nothing, v22.23.2
still lost 7 orderings across A1 and A3, every one a write that had not landed.

So drain on both observables. tests/helpers/mock-timer-drain.ts wraps the MOCKED
setTimeout with a counter, giving an exact count of timers scheduled and not yet
fired or cleared, and each round also issues its own import and waits for it, so
the wait scales with the loader instead of guessing at it. The drain returns when
no timer is outstanding and three consecutive rounds neither scheduled nor fired
one. With the probe, three quiet rounds is green on v22 and v24 alike.

The generous bound stays as a BACKSTOP that THROWS and names what was still
outstanding, which is the change that matters most here. An under-drain used to
be indistinguishable from the store losing an edit, so it sent the investigation
into the store for two rounds. Now it says so:

  settle: drain exhausted after 2 rounds, with no timer pending but work still
  scheduling or firing within the last 3 rounds. Nothing read after this point is
  trustworthy: a queued write has not landed, so the store still shows the
  PREVIOUS value, which reads as a wrong value rather than a missing one. Fix the
  work or raise the backstop; do not read this as the store losing an edit.

It is also much faster, because it stops when the work is done rather than after
600 rounds regardless. A typical drain now takes 4 rounds; instrumented over 840
drains in A1 the maximum was 4.

  simulation  v24 101.7s -> 30s      v22.23.2 106.5s -> 29s
  compat      v24 0.54s -> 0.46s     v22.23.2 0.61s -> 0.52s

The three copies of the drain shape, one in the world helper and one in each test
file, are now one helper. The dead end already recorded is kept next to the new
measurements: quiescence on the store ROWS is still wrong, because the rows only
change WHEN the write lands.

Verified: both suites green twice each on v24.14.0 and on v22.23.2, the version
setup-node 22 resolves to. Full frontend suite 4080 passed, 0 failed. The
exhaustion throw was confirmed by forcing the backstop to 2, which produces the
message above and no wrong-value violation. A deliberately broken
sanitizeThreadScopedSettings still produces a real ordering violation on both
runtimes, so the drain is not exiting early. Test files only.

* Read the workflow and the dialog as UTF-8, not as the platform default

test_source_read_encoding caught four read_text() calls this PR added with no
encoding. It is right and they are a real defect: the guard reads
settings-dialog.tsx, playwright_settings_tabs.py and studio-frontend-ci.yml, and
on a Windows runner Path.read_text() uses the ANSI code page, so any non-ASCII
byte in any of them raises UnicodeDecodeError. The whole point of this guard is
that a settings page can be added without anyone noticing; a guard that cannot
be collected on Windows fails the same way.

Repo tests (CPU) was otherwise clean: 1 failed, 8781 passed.

* Tell a pwsh crash apart from install.ps1 losing its exit code

test_the_pwsh_filter_keeps_the_log_clean_and_the_exit_code_intact went red on a
hosted ubuntu runner with completely empty stdout and pwsh's own banner: "An
error has occurred that was not properly handled. Additional information is shown
below. The PowerShell process will exit." The interpreter died; the script never
ran. Workflow trigger lint is green on the last 8 main runs and this PR touches
no PowerShell, so it is the runner, not the repo.

Read as an ordinary failure it says "$LASTEXITCODE did not survive the added
pipeline stages, so a failing install.ps1 would leave its step green" -- an
accusation against the installer, raised by a run that produced no evidence
either way. That is the same shape as the drain in this PR: an environment
shortfall wearing the costume of a product regression.

_run_pwsh retries only that case, and the distinction is what keeps it honest:
a run that reaches the `RC=` line is returned on the first attempt whatever the
value, so a genuine regression can never be retried into green. Only a run with
no RC= AND the crash banner is retried, because it carries no verdict to
preserve. If both attempts crash it fails with a message naming the interpreter,
not install.ps1.

17 passed. Exercised _run_pwsh against a script that prints the banner and no
RC=: it raises, so the branch is not vacuous.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 23:55:01 -07:00
Daniel Han
18b97f8b72
Studio: keep and search the turns rolling context evicts (#9074)
* Studio: add rolling context windows for local GGUF chat

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

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

* Studio: return rolling context metadata for non-stream chats

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

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

* Studio: keep original context when rolling fit fails

* Studio: keep instruction groups independently protected

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

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

* Studio: preserve rolling context metadata across retries

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

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

* Studio: count sanitized rolling context prompts

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

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

* Studio: refit rolling context after respawn

* Studio: scope middle truncation to passthrough

* Studio: refit tool prompts after respawn

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

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

* Studio: retain later choice truncation metadata

* Studio: report clipping-only context truncation

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

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

* Studio: expose the turns a rolling fit evicts

Rolling context eviction currently drops turns with no way for a caller to
learn which ones went. Extract the turn grouping so the eviction unit is
reusable, add an identity-based diff of what a fit removed, and let a caller
reserve room for content it intends to add back after fitting.

The reserve deliberately does not affect whether trimming happens at all, only
how far it goes once it is already required, so a conversation that fits today
is still returned untouched.

* Studio: archive evicted turns into a per-thread rag scope

An evicted turn is currently gone for the rest of the session, so the model
will state the conversation began wherever its visible context begins. Keep the
turns the rolling window drops in a searchable scope built on the existing
store, chunker, embedder and hybrid retrieval.

The archive is cumulative: every compaction adds to it and nothing is cleared,
so a later compaction can still find what an earlier one evicted. It lives in
its own scope rather than the thread's document scope, because thread documents
are injected in full on every request and would re-inject the whole history.

Idempotent by content hash, since the same turns are evicted again on every
later request. Every entry point degrades to a no-op rather than raising.

* Studio: recall archived turns on the turn that evicted them

Given only a search tool, a model decides for itself whether to look, and
mostly does not. Measured on MRCR v2, a 35B declined on 56% of rows, scoring
0.099 when it skipped against 0.461 when it searched. Forcing one retrieval on
the compaction turn took tool-only 0.258 to 0.604, and the model then called
the tool on 0% of rows, so the common path costs nothing extra.

Recall fires at most once per request, since the tool loop refits on every
iteration. The tool loop renders it as an ordinary tool exchange through the
builder shared with document auto-inject; the plain path prefixes the latest
user message instead, because it sends no tools array and a tool role without
one breaks strict chat templates.

Only scalar counts join the context_truncated event, so no message content
reaches the wire.

* Studio: let the model search a compacted conversation

Forced recall answers the turn that evicted, but a later turn can refer to
something the forced pass had no reason to fetch. Add search_conversation so
the model can go looking, scoped to the thread's own archive and sharing the
admission slot with document search so the two cannot race for the embedder.

The tool is offered only once a thread has actually had turns archived, so an
ordinary short chat never pays for the schema, and it is classified read-only
so auto mode does not prompt on every call. A matching system-prompt note tells
the model the session was compacted, since otherwise it assumes the
conversation began where its visible context begins.

Deleting a thread now drops its archive rather than leaking a scope per chat.

* Studio: read thread_id defensively when selecting tools

_select_request_tools also serves the token-count request model, which has no
thread_id field, so the archive gate raised there.

* Studio: retrieve archived turns lexically first

Recalling your own conversation is mostly an exact-match problem: a name, a
number, an identifier someone pasted twenty turns ago. Those live or die on
rare-token matching, and hybrid fusion was losing them.

Measured on a 30-turn walkthrough of a 230k-character document at a 16k window,
where every turn shared the same wrapper text. The chunk holding the needle
ranked 3rd lexically at any k, was never returned by dense retrieval at all,
and RRF pushed it to 16th because it had 30 useless dense hits to fuse with.
End to end the model answered with the exact code once lexical leads, and could
not answer at all before.

Dense still fills whatever the lexical pass leaves, for paraphrased recall.

* Studio: keep recall on the branch the user is actually on

Editing an earlier message rewinds a thread and continues down a new branch,
but the archive is append-only and still holds everything the abandoned
continuation produced. Verified against a live build: after rewinding past a
turn, querying its distinctive text still returned it, so the model could be
handed a turn that on this branch never happened.

Recall now drops archived turns that are absent from the thread's saved
transcript. Threads with no saved transcript are left unfiltered, since an API
caller may pass a thread_id without persisting messages and an empty transcript
is absence of evidence rather than evidence the turns are gone.

Containment on a normalised prefix rather than a digest, because the archived
copy is rendered from the inference projection and the saved copy comes back
through the message store.

* Studio: show a persistent compaction notice on the turn that compacted

The only signal that a long chat had been compacted was a toast, which vanishes after
a few seconds and does not survive a reload. A user who scrolls back later has no way
to find out why the model seemed to forget the start of the conversation.

The notice renders from metadata.custom.contextTruncation inside the assistant
message's own container, so it is not part of the conversation sent to the model, is
not editable, and is not exported as content, but it stays attached to the turn it
describes. It reports how many messages were dropped and, when the conversation
archive is on, that they are still searchable and how many passages were recalled.

* Studio: show the compaction notice once, on the turn it started

A thread that has outgrown its window compacts on every turn from then on, not
just the first, so a notice per compacted turn was a notice on every reply for the
rest of the conversation. The user needs telling once.

The notice is now gated on being the first compacted assistant turn in the thread,
found by walking the thread rather than assuming the compacted turns are contiguous
or that this is the last one, so a rollback that removes the turn it was on moves it
to whichever turn now compacts first. The wording follows: it describes the state
the conversation is in from here on, and carries the counts from the turn it began
on in parentheses.

* Studio: make compaction an occasional event instead of every turn

The fit was stateless. The client re-sends the whole saved transcript on every
request, so "keep the newest N tokens" recomputes from scratch each time and slides
forward a turn or two at a time. Measured on a 40-turn thread against an 8k window,
the eviction boundary moved on 12 of 40 turns, which means every few replies quietly
lost a little more of the conversation, llama-server's prefix cache was thrown away
each time the head of the prompt moved, and there was no such thing as a compaction
event to tell the user about.

Two changes make it discrete. The fit now reads back the boundary the thread last
compacted to, from the newest assistant turn's own persisted truncation, and reapplies
it before deciding anything, so nothing new is stored and it survives a restart. And
when the boundary does have to move, the trim takes a further ROLLING_COMPACTION_
HEADROOM_RATIO of the budget out (default 0.25) rather than skimming to the brim, so
the new boundary has room to stay put. Same 40-turn thread, same window: 4 compactions
over 60 turns instead of 14, keeping about 82 percent of the usable budget.

Both are gated on the prompt not already fitting, exactly as the recall reserve is. A
conversation inside its window is never evicted to satisfy either, and a stale boundary
from a branch that was rolled back cannot evict a chat that now fits.

The notice follows: it is shown when dropped_messages rises above the last turn that
reported it, so it appears once per compaction and stays quiet in between.

* Studio: pin that the compaction notice can never become conversation

The notice is a sidecar rendered from metadata, not a message, and the ways that
could quietly stop being true are all one careless edit away: moving it inside
MessagePrimitive.Parts would make it a content part, and everything that walks parts
would then replay it to the model, copy it and export it.

Asserts it renders as a sibling of the content parts, that neither the outbound
message builder nor the assistant replay serialiser reads the key it renders from
(bounded to those function bodies, since the streaming handler reads the same key
legitimately on the way in), that the markdown export does not mention it, and that
it is suppressed while editing so it cannot be typed into the textarea and saved
back as text.

* Studio: say which part is too long when nothing can make a turn fit

If the message just sent is itself bigger than the window, no amount of eviction
helps. The fit already handled that correctly, returning the conversation untouched
so the request reaches llama-server's normal context-length error, but what the user
was then told was actively misleading: the error reports the size of the WHOLE
conversation and advises shortening it, when the history has already been evicted and
the single message is the part that does not fit. Measured at a 4096-token window: a
5000-token message produces 'Message too long: 10290 tokens ... shorten the
conversation', and shortening it cannot possibly work.

The fit now returns a fits:false diagnosis instead of a bare None, carrying what the
conversation could not be reduced below and how much of that is the latest turn.
Every consumer already gated on fits, so this is inert wherever a truncation is
treated as a compaction; the streaming paths now forward it so the client can use it.

The toast reads the diagnosis and, when the latest turn alone exceeds the window,
says so with the numbers instead of offering advice that leads nowhere. The merge
drops the diagnosis once a later refit succeeds, by delete rather than by assigning
undefined, so an ordinary response keeps exactly the shape it had before.

* Studio: fix the review findings on the conversation archive

Fifteen items, each reproduced against the code before changing anything.

Correctness in the request path. The fit reported a successful recall-capable fit
whenever the result was under the prompt budget, but protected messages can stop the
trim reaching the reserve target, and the recall then went in anyway: reproduced at
ctx 8000, the fit accepted at 6900 and recall took the request to 8948, past the
window it had just been made to fit. Recall is now sized from the room the fit
actually obtained, and skipped when there is none. Separately, the sticky boundary
describes the original transcript, so re-applying it on a later tool-loop fit evicted
another boundary-sized block of live history: measured, a second fit dropped 28 of the
30 surviving messages instead of 14, and the summed count persisted an inflated
boundary for the next request. It is now spent after the first fit of a request.

Availability and privacy. enabled() trusted RAG_AVAILABLE, which only records that
import sqlite_vec worked; rag_available() exists because the native vec0 library it
loads is a separate file a venv can lack. On such a machine the fit held a recall
reserve back, evicting extra history, and then both the archive write and the recall
failed, so the user paid for content they never got. And a temporary chat is never
written to studio.db, yet the frontend still sends its thread_id and the request
carries no incognito flag, so its turns were archived to a scope no deletion flow
could reach. Archival now requires the thread to be persisted, which is the same rule
that keeps every archive reachable by a delete. Clear-history and project deletion
drop archives too; only DELETE /threads did.

Archive integrity. The document was committed before its chunks, so a failed chunk
write left an empty row marked completed that document_by_hash then skipped forever;
both now go in one transaction. The live-branch filter accepted a turn on its first
matching line, so editing only the assistant half kept serving the old answer; the
whole turn must be present. Retrieval fetched exactly k before that filter, so stale
turns could starve live ones and recall returned nothing; it over-fetches first. Tool
turns archived only the tool name, which cannot answer what was actually run, so a
bounded rendering of the arguments and the assistant text goes in as well.

The tool surface. Studio always sends an explicit enabled_tools array and has no
reason to name an internal tool, so the allowlist filter removed search_conversation
before the archive gate ran and the tool, plus the compaction nudge gated on it, never
appeared in a Studio chat. It now follows the archive rather than the allowlist. The
forced recall rendered a tool exchange even when the tool was absent from the
catalogue, which is the strict-template hazard the plain path avoids; it picks inline
in that case, and always inline for the final-answer request, which sends no tools at
all. A model-supplied top_k reached a slice as out[:-1] and returned nearly the whole
candidate pool, so it is clamped. Both retrieval tools now share the per-turn search
cap; only the knowledge-base one was counted.

The UI. A fits:false diagnosis is the fitter reporting it could NOT fit, so toasting
that older turns were removed was untrue and burned the once-per-thread flag a later
real compaction needed. The too-long advice compared the latest turn against the raw
context length rather than the prompt budget, so a 3,500-token message in a
4,096-token window was still told to start a new chat, which fails identically.

* Studio: fix the follow-up findings on the archive fixes

Four items, three of them about last round's own fixes.

Archived tool turns had become permanently unrecallable. render_turn now writes
'assistant called X: args' and 'tool result: ...' lines, while assistant-ui persists a
tool call as a structured tool-call content part that the transcript flattener dropped;
with every archived line required to appear in that transcript, no tool turn could ever
match. The transcript now flattens toolName, args and result, and the probe strips the
'assistant called <name>:' label, which is ours rather than the stored message's.

The branch probe compared only the first 160 normalized characters of each line, so an
edit to the tail of a long answer left the stale copy eligible. Ordinary lines are now
compared whole; tool results keep a prefix, since render_turn deliberately truncates
those and the archived copy is not meant to equal the stored one.

The forced recall sized itself by dividing the remaining budget by CHUNK_TOKENS, which
is an embedding-token limit rather than the chat template's cost, and prices none of
the wrappers around the injection. It is now recounted with the same tokenizer the fit
used and dropped if it overshoots, so the estimate can no longer eat the reply reserve.

An omitted top_k on search_conversation defaulted to the clamp ceiling of eight rather
than the configured recall default, so an ordinary search could return eight archived
turns into the protected current exchange that rolling truncation cannot evict.

* Studio: filter conversation recall to the active branch

A thread's stored rows are the whole message DAG. Retry and regenerate keep the
replaced response as a sibling on purpose, so filtering recall against the whole
thread cannot tell a live turn from one the user replaced, and an archived copy of
the abandoned response could be recalled into a branch where it never happened.

Filter against the messages the request was actually sent with instead, which is
one branch by construction, and hand the same branch to search_conversation so the
model cannot ask for what the forced recall refused. Falls back to the thread-wide
blob for a caller with no branch to offer.

Also retunes two respawn-refit fixtures whose windows no longer produced two
compactions after compaction started trimming a headroom margin below the budget.

* Studio: survive a delete mid-archive, and read the boundary off the active branch

Deleting a chat cancels its generation, but cancellation is cooperative and the
chunk-and-embed pass between the archive's liveness check and its commit does not
observe it. A delete landing in that window drops the thread's rows and sweeps its
scope before the commit puts rows back, leaving content the user deleted in a scope
no later delete can reach. Re-check after the commit and drop the scope: the delete
route removes rows first and sweeps archives last, so either order converges.

The sticky compaction boundary had the same thread-wide read as recall did. The
stored rows are the whole DAG ordered by creation time, so after a Retry the newest
assistant turn can be the sibling the user switched away from, and its boundary is
sized for history the active branch does not have. Resolve it against the request's
own messages instead.

* Studio: give the safetensors loop the same conversation-search guards

search_conversation is advertised by thread, not by backend: the tool selector is
shared, so a chat compacted under a GGUF model still offers it after the user
switches to a safetensors one. The safetensors loop had neither guard the GGUF loop
applies to it. It passed no active branch, so a search there fell back to the
thread-wide rows and could answer from a branch Retry left behind, and it capped
only search_knowledge_base, so paraphrased conversation searches could append
archived passages into the protected current exchange on every iteration until the
window failed. The shared set of capped retrieval tools now lives beside the cap.

The forced recall also derived its query from the loop conversation, which on a
later iteration can end with an internal user-role re-prompt rather than anything
the user wrote. It reads the request branch's own latest user turn instead.

* Studio: widen recall past an abandoned branch, and name whose turn overflowed

One over-fetch is not enough for the live-branch filter. Rewinding or retrying a
continuation that had already been compacted leaves enough stale turns to fill any
fixed candidate window, and the whole page is then rejected while the live match
sitting just below it is never examined, so recall reports nothing although the
answer is in the archive. Widen and re-ask instead, stopping as soon as there are
enough live hits, when the archive stops yielding candidates, or at a bound.

The irreducible-fit diagnosis also carried the size of the last message without
saying whose it was. A tool loop refits with the tool result appended, so that turn
is often output the user never wrote and cannot edit, and the client told them to
shorten it. It now reports the role, and the advice splits on it.

* Studio: shrink an over-budget recall, and keep truncated tool turns on their branch

The exact recount is the right gate, but dropping the whole recall when it fails is
the wrong response: with the shipped defaults a full top-K of long turns lands just
over the reserve once the wrappers are priced, which would disable the forced
retrieval on exactly the long conversations it exists for. Halve the number of turns
and re-ask instead, down to one, before giving up.

The live-branch probe keyed its prefix rule off the tool-result label, but a long
tool result is one appended string containing many newlines, so only its first line
carries that label. Continuation lines were compared in full, including the last one,
which is the only line the truncation marker is on and can therefore never appear in
a transcript: every archived tool turn over the cap was rejected as rolled back.
Key off the marker instead, which also covers truncated tool arguments and compares
the full text everywhere else.

* Studio: retire an edited turn's whole archived copy, not just the edited chunk

A turn longer than CHUNK_TOKENS is stored as several chunks of one document, and the
live-branch filter ran per chunk. Editing the second half of a long answer therefore
retired only the chunks carrying the edit, and an untouched earlier chunk of the same
retired turn stayed eligible on its own. The unit that was archived is the turn, which
is what the filter already claimed to enforce.

Validate every chunk of the candidate's document before admitting any of it, cached
per call since candidates from one turn share a document. A query failure falls back
to the per-chunk answer rather than failing the recall.

* Studio: do not hold a recall reserve back for a chat that is never archived

archive_turns refuses a thread with no saved messages, because a temporary chat must
not be persisted into a scope no deletion flow can reach. The reserve did not follow
that rule: it was granted whenever the RAG stack was available, so an incognito chat,
or an API client sending a thread_id without saving anything, paid a full 2,048-token
reserve for content that can never arrive. The fit subtracts the reserve from its trim
target, so that room is bought with evicted history. Measured on a 4K window: 15
tokens of conversation survived a compaction instead of 1,615.

Both now ask one predicate, can_archive, backed by an existence probe rather than a
row load.

* Studio: match archived turns in order, and delete them without sqlite-vec

Independent line membership accepts a turn whose lines were merely rearranged: every
probe still occurs somewhere, so the pre-edit ordering stayed eligible and would be
served back as what happened. Match the probes in order instead.

That only works if both sides agree on the order, and they did not: render_turn writes
a tool call before any assistant text on the same message and the result after it,
while both transcript builders wrote the text first, so a tool turn carrying both was
rejected outright. One flattener now lays out a message the way render_turn does, for
the request shape and the stored shape alike, and it offers both JSON spacings for a
stored call's arguments, whose object form is not the string the model emitted.

Deletion no longer depends on the optional native extension. An archive is only written
while vec0 loads, but the library can stop loading afterwards, and a delete that quietly
did nothing left a deleted conversation's turns on disk to answer again once it loaded.
The fallback removes the text-bearing rows over a metadata connection; the embedding
rows it cannot reach carry no text and resolve through tables that are gone.

* Studio: give the provider loops the branch, and keep the thread across a respawn

The provider tool loops take their catalogue from the same selector as the local ones,
so search_conversation is advertised there too once a thread has an archive, but the
loop passed no active branch and its searches fell back to the whole stored DAG, where
Retry keeps the response it replaced. This is the third loop to need the same wiring.

The respawn retry also dropped the thread. It re-enables context_overflow on purpose,
to refit an already-compacted prompt for a replacement window, so it is the one path
that deliberately compacts a second time: without the thread those extra evictions were
archived nowhere, nothing was recalled in their place, and the fit held back no reserve
and re-applied no boundary.

* Studio: keep the reply that follows a recall, and archive respawn refits

group_turns keeps an assistant tool call, its result and the reply that follows in one
group, and the archiver rejected any group containing one of our own injections. So on
a turn that forced a recall, the model's actual answer was thrown away with it: the
question was archived from its own group and the answer was not, and a later search
could find what was asked and never what was said. The injections come out now and the
rest of the turn stays; retrieved passages are still kept out of the index they came
from.

The two respawn refits inside the tool loop also evicted without archiving. They run
against a smaller replacement window, so those turns are simply gone otherwise. They
archive only, deliberately: no reserve is held back on that path, so injecting a recall
there is what would push the retry back over the window, and the next request can still
recall what this one archived.

* Studio: bound the branch check to one turn, and anchor only what recall injected

The branch check searched one flattened transcript, so a line an edit removed could be
supplied by any later message that happened to repeat the words. Short answers repeat
constantly: an archived "Should I deploy? / No" survived its answer being edited to
"Yes" because a later turn said "No", and the stale pair stayed recallable. The
transcript is now one normalised string per message, and a turn matches only if its
lines appear in order within a run of adjacent messages no longer than the turn itself.

Recall anchoring took the last two messages of the conversation. That is right for the
tool style, which appends a synthetic pair, and wrong for the inline style, which
appends nothing and rewrites the latest user message in place: it also pinned the
assistant turn before it, and with it an eviction unit the fit was entitled to drop,
which can fail a later iteration that would otherwise have fit. The injection now
reports exactly what it added or rewrote, and only that is anchored.

* Studio: validate a turn's chunks against one run of the branch, not each on its own

The chunks of an archived turn are consecutive slices of a single rendering, and each was
checked for itself. That let a turn be reassembled out of parts that never sat together:
the head matching the question and the answer it has now, the tail matching some later
message that happens to repeat the passage an edit removed, so the stale association
stayed recallable. Reproduced before the change, with each chunk passing on its own.

All chunks of the document must now be found within one run of adjacent messages, bounded
by the document's own line count, which is at least the number of messages the turn was
rendered from, so a turn that really is still there always fits.

* Studio: budget conversation searches and disambiguate identical replies

search_conversation clamped the model's top_k only against a fixed ceiling of 8.
Eight chunks is roughly 4,000 tokens once wrapped, and the result lands in the
current tool exchange, which rolling truncation protects and cannot evict, so on
a small context the search itself made the turn unsendable. The GGUF loop now
passes the room the window actually has left, the tool clamps top_k by it, and a
search with no room says so instead of returning a result that cannot be sent.

The sticky compaction boundary took the newest on-branch assistant row, but the
branch check is textual, so two siblings whose replies read the same ("Done.")
are indistinguishable from there, and Retry is exactly what produces them.
Taking the first match applied a boundary measured on a different, deeper branch
and evicted live history. Where the text cannot separate them, the smallest
boundary is now used: too small costs one more compaction, too large loses turns
the branch still has.

* Studio: price a conversation search against the whole prompt

Three gaps in the budget the previous commit introduced.

The GGUF loop subtracted an estimate of the messages alone, leaving the tool
catalogue out of the prompt entirely. A large catalogue is thousands of tokens,
so the request could already be near its budget while the search was still told
there was room for several 500-token chunks. The preflight already prices the
request exactly, catalogue and template included, so the difference between that
count and the estimate of the same messages is carried forward and the estimate
only covers what the loop appends after it.

The safetensors loop advertises and executes search_conversation for a thread
compacted under a GGUF model, but forwarded no budget, so the clamp in the tool
was skipped there. It now passes the room this model has left. With no known
context length the argument is omitted rather than sent as zero, which would
refuse every search.

The sticky boundary filtered candidates with a substring test, which is right
for archived chunks and wrong for whole messages: an abandoned "Done" rode in on
a live "Not done yet", and having no live twin it then decided the boundary
alone. Where any candidate matches a live message exactly, only the exact ones
are considered; where none does, the old behaviour stands, since a stored row is
not always byte-identical to what the client re-sends.

* Studio: keep the overflow diagnosis on the tool path

The irreducible-overflow branch counted the newest message on its own, and a
tool loop reaches that branch with a tool result last. A tool result by itself
is not a conversation: templates that require it to follow its assistant tool
call refuse to render one, and the exception escaped the fit entirely, so the
caller fell back to the untrimmed request and the client was told nothing at
all. That is the one path this diagnosis was added for.

The count is now attempted and falls back to the estimator when the template
refuses. An approximate number is worth more here than a diagnosis that never
arrives.

* Studio: budget the conversation search in the provider loop

The third tool loop forwarded the active branch but no budget, so the clamp in
the tool was skipped there and a model-chosen top_k of 8 could append roughly 4K
tokens to a prompt this loop replays on its next call.

Studio knows no window for an external model: the request carries no context
length and there is no registry to look one up in, and a custom
OpenAI-compatible endpoint can be a small local server. So rather than a
measured budget, this path spends no more than one ordinary recall's worth,
which is the same amount the compaction turn itself is sized for.

* Studio: bound an archived turn to its own messages, and archive it once

Two problems in the archive.

The whole-document branch check bounded the run by the number of LINES the turn
produced, and gave each chunk its own start. A turn is two or three messages
however long it is, so a hundred-line answer got a hundred-message window, and
the tail of an edited answer could be satisfied by a message well outside the
turn. The run is now bounded by the messages the turn was rendered from, counted
from the labels render_turn writes, and the chunks are scanned as one pass: each
continues where the previous one stopped rather than restarting. The cursor
inside a message is deliberately not carried over, because chunks overlap and a
continuation chunk repeats the tail of the one before it.

The hash check that skips an already-archived turn ran long before the insert,
with the embedding pass in between, and the index on (scope, sha256) is not
unique. Two generations compacting the same thread both cleared it and both
wrote, so the turn was stored twice and its copies took two of the few recall
slots. The check is now repeated under a write lock immediately before the
insert. Reproduced with two concurrent archive passes: two documents before, one
after.

* Studio: record how many messages an archived turn came from

The run an archived turn is allowed to occupy on the branch was bounded by
counting the role labels in its rendered text. That counts lines the user wrote
as well as the ones the renderer did: a pasted chat log carries lines that look
exactly the same, and each one widens the run by a message, which is enough for
the message after an edited turn to supply the passage the edit removed.

The group's size is now recorded on the document when it is archived, in a
nullable column added the way the other lazy upgrades are. Archives written
before this have NULL and fall back to the label count, so nothing needs
backfilling to keep working.

* Studio: persist a compaction boundary the next request can use

The boundary was read back from dropped_messages, which counts what each fit
removed from the conversation in front of it. The tool loop refits on every
iteration and the client sums those counts, so a long agent run added the tool
exchanges the turn itself created, and the next request applied the total to its
saved transcript. Reproduced with six tool calls on a 4K window: three fits of 4
summed to 12, on a branch that only ever had 4 evictable messages.

The boundary is now carried separately, measured against the messages the
request was sent with, so it is absolute and re-sending it cannot advance it.
The client keeps the latest value rather than summing, and turns saved before
this fall back to the dropped count, which is the same number for a turn that
fit once.

The recall reserve is also dropped once archiving has failed. sqlite-vec can be
present and the thread saved while the embedder cannot start: archive_turns
swallows that and recall injects nothing, so the room the fit held back was pure
loss on every compaction, and the failure mode forgot more history than having
the feature off. A failed write marks the archive degraded and the next
successful one clears it.

* Studio: count the boundary past the system prompt, and notice it

Two faults in the boundary added in the previous commit.

It stopped at the first message still present, and a Studio request always
starts with a system prompt that a fit never evicts, so every compaction
recorded a boundary of zero. That is the same as having no boundary: the next
request would move the eviction point again and invalidate the prefix cache on
every turn. Instruction messages are now skipped rather than treated as the
front of the branch, and the newest turn is excluded because it is never evicted
and an inline recall rewrites it in place.

The compaction notice still keyed off dropped_messages, which is the
accumulated count. A tool-heavy turn reporting 12 while the boundary moved to 4
set a high-water mark that silenced the next two real advances. It now reads the
boundary, falling back to the dropped count only for turns saved before the
boundary was recorded.

* Studio: tighten comments in the conversation archive and rolling context window

* Studio: give the pasted-text import an extension on this branch too

The node test runner cannot resolve an extensionless relative import, so
delete-chat-files-preference fails to load the preferences store. main fixed
this in d43892ea7; this branch predates it, and the same line is on the branch
below it. The change is identical to main's, so it disappears on merge.

* Studio: size a conversation search by what it renders to

CHUNK_TOKENS is what the chunker aims at, not what a chunk weighs: chunks
overlap, the chunker's tokenizer is not the model's, and the rendered block adds
markup, source metadata and the tool framing around it. Dividing the budget by
CHUNK_TOKENS therefore permitted a result far larger than the room measured, and
it lands in the current exchange, which the rolling window cannot evict.
Measured on a 500-token budget: one chunk came back at 1,256 estimated tokens.
The count is now halved until the rendered result fits, the same backoff the
forced recall uses, and a single chunk that still does not fit is refused.

The late archive cleanup also spared a recreated thread. DELETE removes the rows,
awaits the sandbox pass, and only then sweeps the archive; another tab can POST
the same id in that window and its generation can archive turns under it. The
sandbox pass re-checks for exactly that, and this now does too, so the recreated
chat keeps its memory.

* Studio: keep the configured default when top_k is omitted

Budgeting an omitted top_k by dividing the whole budget treated the room as a
target rather than a cap: on a 128K chat with most of its window free it asked
the archive for 200 passages, past the configured default of 4 and past the
ceiling of 8 that the model's own value is held to. The default now applies as
before, with the room and the ceiling capping it.

* Studio: cut the boundary at the newest user turn

Excluding only the branch's last message assumed the newest user turn is last,
and a continued assistant message puts a prefill after it. The user turn then
stayed in the identity scan, and since inline recall rewrites it into a new dict
it read as evicted, inflating the boundary by one and costing the next request a
live message. The scan now stops at the newest user turn, which is what is
protected in any case.

* Studio: require an archived turn to end where the live message does

Editing a reply by keeping it and adding to it ("No" becoming "No, correction:
yes") left every probe matching, so the pre-edit copy stayed eligible and a
search could return "No" as the answer with the correction nowhere in it. The
scan now reports where it finished inside the message, and the turn is accepted
only if it reaches the end of it.

Tool results and tool arguments are exempt: render_turn cuts them, so their
probes are prefixes by design and demanding the end would retire every turn that
carried one.

* Studio: check both ends of an archived turn, and charge dense text properly

An edit that keeps the old text and adds to it leaves every probe matching
whichever side it adds on. The end anchor caught "No" becoming "No, correction:
yes"; it did not catch "Correction: no", which ends exactly where the archived
copy does. The scan now also reports where the run into the final message began,
and the turn is accepted only if it covers that message end to end. render_turn
only ever cuts the tail, so the start is required unconditionally.

A conversation search sized itself with the shared estimator, which charges four
characters per token. That is an English rule: CJK and emoji run closer to one
token per character, so a result could be accepted at a quarter of its real size
and then land in the current tool exchange, which the window cannot evict. No
exact counter is reachable there, the provider loop having no tokenizer at all,
so non-ASCII characters are charged one token each and the rest at the usual
rate. Measured on 320 CJK characters: 87 tokens claimed, 320 charged.

* Studio: earn the compaction headroom, and keep this turn's tool results

The headroom cut every overflowing prompt to about 75% of its budget, including
on paths that can never put the boundary back: an incognito chat, an API request
with no persisted thread, or one whose turns are not saved. There the deeper cut
buys nothing and cannot be recalled, so it is simply less history than plain
eviction would have kept, and turning the archive off did not restore the old
behaviour. The fit now takes the headroom only when the caller can restore the
boundary next request.

The branch the archive is filtered against was the messages the client sent. A
long agent run evicts, and archives, tool exchanges it created itself, and those
were then refused as an abandoned branch: the model could not search back a tool
result it still needed to answer. The branch is now accumulated across the
request, so this turn's own exchanges count as live, while a sibling response
from a Retry still does not.

* Studio: read the branch as replies when restoring a boundary

The rows the rolling window checks are assistant replies, but the branch they
were checked against was every message of it, flattened without roles. An
abandoned "Done" left by a Retry therefore rode in on a live user message that
merely contains it ("not done yet I think"), and with no exact match to prefer,
its much larger boundary was applied to a branch that never had that reply.

The branch representation now takes a role filter and the boundary asks for
assistant messages only. A branch with no reply of its own has no boundary to
restore, so it reports none rather than falling back to the unfiltered check.

* Studio: account for every message an archived turn claims

The anchors covered the final message only, so an edit to the question
underneath it left the archived copy eligible, whichever side the text was added
on. The scan now checks each message the run touches: it must be matched from
the first character, and nothing may be left over when the run moves on.

Tool calls are exempt, and the exemption covers the whole message. The store
keeps a call as a structured part, so the live text carries the tool name and
both spellings of the arguments, spaced and compact, while the archived copy has
one line of one of them. Nothing there lines up character for character, and
demanding it would retire every tool turn in the archive.

* Studio: re-embed archived turns after a model change, and batch the first pass

Deduplication was by hash alone, but dense search only reads documents whose
recorded embedder matches the query's. A turn archived under the previous model
was therefore skipped on every later compaction while being invisible to every
paraphrased search, permanently. The check now compares the identity too and
replaces the copy, which is what ingestion does with a re-uploaded file.

Embedding also ran once per evicted turn. A first compaction of a long chat sent
dozens of one-item jobs back to back, and both backends serialise them, so the
reply waited for all of them. The turns are chunked first and embedded in one
pass, with the per-turn write and its duplicate check unchanged. Measured on 40
turns: 40 embedding calls before, 1 after.

* Studio: re-pin the permission read the deep-research contract lost to #8686

The contract still asserted the literal `const permissionMode = loadPermissionMode();`,
which #8686 removed when it put a chat-scoped override in front of that read. This branch
already carries the refactored store, so Repo tests (CPU) cannot pass here or on anything
stacked above it.

Ported main's replacement rather than dropping the check. The read is still the contract,
and it is scoped to the deep-research setter so it cannot be satisfied by the initial-state
constant, which is a different property and would keep passing if the read were deleted.

* Studio: re-derive the compaction boundary by position, not by count

`boundary_messages` is an absolute count against the transcript it was counted
on. Delete an already-evicted prompt from a compacted thread and the front of
that transcript gets shorter, but the count is replayed unchanged, so the cut
lands that many messages too deep and evicts turns that are still live.

Reproduced on `fit_rolling_context` with a 30-turn chat at ctx 2000: a fresh fit
keeps from turn 16; deleting one already-evicted pair and replaying the stored
count keeps from turn 17, one live turn lost. The loss is then baked in, since
the next turn records the boundary against the shortened transcript.

Stamp `boundary_anchor`, the text of the first message the fit KEPT, next to the
count at all five sites that write it, and look it up on the next request's own
branch. The anchor is only ever allowed to make the boundary SHALLOWER, so a
stale, ambiguous or repeated anchor costs one extra compaction and can never
evict a live turn. Rows written before the key exists behave exactly as today.

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

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

* Studio: say what the archive kill switch does and does not cover

RAG_CONVERSATION_ARCHIVE=0 stops indexing, recall and the recall reserve. It
does not turn the rolling window back into what it was before it learned to
compact in discrete events: the headroom and the sticky boundary belong to the
window, and the headroom has its own knob. "Off restores plain eviction" read
as though it covered all of it.

Comment only. Gating the window's own behaviour on this flag would make a host
with no sqlite-vec silently compact differently from one that has it.

* Studio: charge dense text honestly, and reject an edit inside an archived turn

Two fixes.

The safetensors search budget priced what is already in the prompt at four
characters per token. That is about right for English and roughly half the truth
for CJK and emoji: measured on an 81-message Japanese chat, 1295 estimated
against 2737 real, reporting 1777 tokens of room where 335 remained. The tool
result is already charged a token per non-ASCII character; this is the same rule
applied to the spend, which was the missing half. That path runs no rolling fit,
so nothing downstream recovers once the exchange lands. The GGUF site takes it
only on its fallback leg, since the fit's exact tokenizer count needs no
correction, and eviction keeps the flat estimate: making eviction pessimistic
would drop history a request could have kept.

The probe scan anchored the start and the end of a run but not the middle, so a
correction inserted between two archived lines matched both probes with the new
line sitting unexamined in the gap, and the pre-edit turn stayed recallable.
Verified on "A\nB" becoming "A\ncorrection\nB". A gap is now allowed only where
it is a label render_turn wrote and the probe had therefore stripped, since a
pasted chat log legitimately carries its own "user:" lines, and the tool-call
exemption still covers the rest of its message.

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

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

* Studio: end the tool-call anchor exemption where the call ends

A stored tool call cannot line up character for character with the live text,
because the store keeps arguments as an object and offers both JSON spellings,
so the cursor after one is not exact and the character anchors have to relax.
That relaxation was then held for the rest of the message.

An assistant turn carrying both a call and text therefore stayed matched after a
correction was appended to the text: "old answer" becoming "old answer,
correction: new answer" left the archived pre-edit turn eligible for recall.

Cleared once an ordinary text probe matches, since the cursor is exact again from
there. Verified on that exact pair: intact still eligible, edited no longer.

* Studio: count a video part as media in the rolling preflight

`_inject_video_part` writes llama.cpp's own `input_video` part into the same
message list the fit is then handed, and the media predicate did not list it.

Video prompts therefore ran the rolling preflight, which is skipped for media
precisely because `/apply-template` token counting does not include the sampled
video tokens. The prompt could be certified as fitting when it does not, or lose
history it never needed to lose, and still fail with context_length_exceeded.

* Studio: keep which tool was called in the branch probe

`render_turn` writes "assistant called <name>: <args>", and the whole label was
stripped before probing, name included, so only the arguments decided. A retry
that kept the arguments and the result and changed only the tool left the
archived pre-edit turn matching: verified on the same arguments moving from
terminal to python, which stayed eligible and could be recalled as though the
old call had happened on this branch.

The name is now its own probe, ahead of the arguments, which is the order
`_probe_text` renders a live call in. The label itself still has to go, since it
exists only in the archived copy, but the name does not: the live text carries
it. `render_turn`'s "tool" fallback for a nameless call is excluded, because the
live text has no name there either.

Not an exact anchor: the name is matched as a substring of the message, so a
tool whose name appears inside the arguments of another can still match. This
closes the ordinary retry case rather than every construction of it.

* Studio: do not archive a search the model asked for

`_is_injected` recognises the ids this feature and the RAG auto-inject generate,
and a search the MODEL issues carries neither: the parser gives it an ordinary
`call_N` id. Both the call and the passages it retrieved were therefore indexed
as fresh conversation, and a second search archived the first one's output inside
its own, one nesting level per distinct search, each copy competing for the four
recall slots.

Dropped by tool NAME instead, reusing `RAG_SEARCH_TOOLS`, which both loops
already share. Only the retrieval parts go: a reply that follows a search is real
conversation and is still archived, and an assistant message carrying a retrieval
call beside an ordinary one keeps the ordinary one and its result.

* Delete the archived conversation even when its thread id comes back

The late archive sweep skipped the whole scope when another tab had recreated
the id, which spared the recreated chat's memory but kept the deleted
conversation's too. The scope is keyed by thread id alone, so those turns stayed
recallable in the new chat with nothing left to sweep them, and the endpoint
reported success.

Take a cutoff before the rows go and bound the delete by created_at when the id
has come back: everything archived before the delete was accepted belongs to the
deleted conversation, everything after to the new one. Applied at DELETE
/threads, project delete and clear all, and on the no-vec0 path so it cannot
delete more than the vec0 path does.

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

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

* Studio: tighten rolling context window comments

* Stop folding case when validating an archived turn against its branch

A turn corrected only in capitalisation still matched its archived copy, so the
branch filter kept the pre-edit document live and a recall could answer with the
spelling the user had just fixed. Nothing else retires it: the edit changes the
digest, so the corrected turn is written as a new document and the branch filter
is the only thing that could have dropped the old one.

Trim both ends rather than only the right. Keeping leading whitespace looks
tighter and is worse, since render_turn strips the whole message and a live turn
opening on a space or a newline then starts its run at a non-zero offset and is
retired outright. Measured: '   hello there' and a pasted block opening on a
newline both went from live to retired. Indentation-only edits stay tolerated in
both directions as a result, because probes are matched per line and as
substrings; closing that needs the splitter to carry offsets.

Also fixes an unrelated retirement found while measuring this. When
render_turn's 4000 character cut lands exactly on a newline the marker becomes a
line of its own, strips to empty, and was dropped along with the truncation flag,
so the last real probe read as complete and an unedited over-cap tool result was
retired. Measured on a 900 line result: no query could return it.

Note for anyone testing an upgrade in place: boundary anchors written by an
earlier build of this branch were stored folded, so they will not match until the
next compaction rewrites them. The anchor can only ever move the boundary
shallower, so the cost is one extra compaction.

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

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

* Let search_conversation through the Anthropic gate, and probe a tool call before its answer

Adding the schema to ALL_TOOLS made the Anthropic selector pick it while
_ANTHROPIC_UNPROMPTED_SAFE_TOOLS still listed only web_search and
search_knowledge_base, so the pre-switch guard classified a read-only tool as
confirmation-gated. Measured: enable_tools with enabled_tools
['search_conversation'] returned 400 on auto and on the omitted default, with
the terminal/python message, even though is_potentially_unsafe_tool_call marks
it always safe and that set is documented as mirroring it. Pre-PR the same
request was served, so this is a regression the PR introduced.

Separately, _probe_text bucketed a whole message as call, text, result, which
only holds while the text came before the call. A persisted row whose tool call
is followed by the model's final answer, the ordinary agent turn, renders as
call, result, answer, so _scan_probes advanced past the answer to find the
result and could not find it again. Measured end to end on the persisted-row
transcript: recall returned the user's question alone and the document holding
the answer was filtered out. The buckets now flush when text arrives after a
call, the same rule the replay serializer uses; text written before a call still
rides ahead of it.

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

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

* Record the transcript span, and price the recall budget exactly when nothing was trimmed

archive_messages bounds the branch check's run, and it was recording the number
of messages ARCHIVED rather than the span of the turn. The two differ whenever
_archivable drops something: an assistant batch that called search_conversation
alongside an ordinary tool archives three messages while the live transcript
holds four. Measured, validation failed at three and passed at four, so a
perfectly valid ordinary-tool exchange and the answer that followed were
rejected as off-branch and could never be recalled.

fit_rolling_context returns None when it drops nothing, so a prompt that simply
fits, after a context-length increase or on a shorter branch, left the recall
budget to a character estimate that cannot see the template's own framing.
Measured on a request whose real prompt is about 2800 tokens of a 3584 budget:
the budget came back 3512, nearly the whole window, so the recall could append a
passage that does not fit and the next iteration cannot evict it again, since the
current tool exchange is protected. Priced exactly instead, once per request and
only when the model actually reaches for a retrieval tool on a request that did
not truncate. A failure falls back to the old estimate.

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

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

* Price the recall result with the model tokenizer, recount every search, and anchor on tool calls

Count the search result with the caller's own token counter instead of the ASCII
heuristic, recompute the exact prompt count on every search rather than once per
turn, and take the boundary anchor from a message's tool calls when it has no text,
so a tool-call message no longer records an empty anchor and disables the rebase.

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

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

* Widen a reused turn's window to its longest span, and price every search exactly

A three-message tool exchange and a four-message batch holding the same exchange render
identically, so the second is skipped as a duplicate and inherited the shorter window,
which the branch check then used to reject it. Grow the stored span instead, upwards
only. Count the prompt exactly on every conversation search rather than only when the
fit dropped nothing.

* Let an overlapping chunk restart far enough back to cover its carried messages

A chunk's leading overlap is the previous chunk's tail, and it can carry a whole short
message with it, so resuming the scan at the message the previous chunk finished in
could never match it and retired unedited turns as off-branch. The forward position is
still tried first and the walk back stops at the previous chunk's own opening.

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

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

* Widen the span on the locked duplicate path as well

Both turns can arrive in one compaction: the pre-check clears both before either is
written, the shorter is written first, and the longer then met the re-check under the
write lock and left the window at the shorter figure.

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

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

* Bound the persisted anchor, and spend any room at all on one recall attempt

The anchor rides in every truncation event and every assistant turn's metadata while the
boundary stays sticky, so a large pasted message was copied across the thread; cap it at
a head on both sides, where the read side already clamps only shallower. And a budget
below one chunk is not no budget: CHUNK_TOKENS is a ceiling, not the size of a turn, so
try one and let the exact recount reject it.

---------

Co-authored-by: alkinun <alkinunl@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-19 17:40:23 -07:00
alkinun
645ca5c7bd
Studio: add rolling context windows for local GGUF chat (#8961)
* Studio: add rolling context windows for local GGUF chat

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

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

* Studio: return rolling context metadata for non-stream chats

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

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

* Studio: keep original context when rolling fit fails

* Studio: keep instruction groups independently protected

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

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

* Studio: preserve rolling context metadata across retries

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

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

* Studio: count sanitized rolling context prompts

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

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

* Studio: refit rolling context after respawn

* Studio: scope middle truncation to passthrough

* Studio: refit tool prompts after respawn

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

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

* Studio: retain later choice truncation metadata

* Studio: report clipping-only context truncation

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

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

* Studio: re-pin the permission read the deep-research contract lost to #8686

The contract still asserted the literal `const permissionMode = loadPermissionMode();`,
which #8686 removed when it put a chat-scoped override in front of that read. This branch
already carries the refactored store, so Repo tests (CPU) cannot pass here or on anything
stacked above it.

Ported main's replacement rather than dropping the check. The read is still the contract,
and it is scoped to the deep-research setter so it cannot be satisfied by the initial-state
constant, which is a different property and would keep passing if the read were deleted.

* Studio: count a video part as media in the rolling preflight

`_inject_video_part` writes llama.cpp's own `input_video` part into the same
message list the fit is then handed, and the media predicate did not list it.

Video prompts therefore ran the rolling preflight, which is skipped for media
precisely because `/apply-template` token counting does not include the sampled
video tokens. The prompt could be certified as fitting when it does not, or lose
history it never needed to lose, and still fail with context_length_exceeded.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-19 17:27:41 -07:00
Vineeth Sai Varikuntla
a6f267a4f5
Keep a sampler's std and uuid format across a save and reopen (#9225) 2026-08-19 15:34:38 -03:00
Etherl
e99dfe5a2b
Studio: use a custom llama.cpp build (#9292)
* feat(studio): add custom llama.cpp path setting

* fix(studio): harden custom llama.cpp selection

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

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

* Fix custom llama.cpp path resolution

* Shorten llama cpp path comments

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

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

* Distinguish non-executable llama server paths

* Reload after llama server path changes

* Refresh llama consumers after path changes

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 08:09:26 -07:00
Michael Han
3cb8ecce78
Studio: add an edit_file tool so agents stop rewriting whole files (#8753)
* Studio: add an edit_file tool so agents stop rewriting whole files

The tool loop had no way to change a file. ALL_TOOLS was web_search, python,
terminal, render_html and search_knowledge_base, so the only way to edit
anything was a whole-file `cat > f <<'EOF'` through terminal or an
open(...).write(...) through python. Both re-send the entire file to change one
line, and both lose whatever the model failed to reproduce verbatim.

Measured on a 520-line source file: a one-line change costs 7,750 output tokens
to rewrite versus 45 to patch. Over five edits with re-reads that is 79,390
tokens against 8,676, which is why tasks that should fit in 64-94K die past
100K.

edit_file replaces an exact string. Not a unified diff: models corrupt @@ hunk
headers far more often than they mis-copy a literal snippet, and a bad hunk
header patches the wrong place instead of failing. A missing or non-unique
old_string is a hard error naming the match count and writes nothing, so the
retry is "add context" rather than "recover a mangled file".

- Preserves CRLF line endings, UTF-8 BOM and file mode. old_string is matched
  against normalized text, so a snippet with plain newlines still matches a
  Windows-authored file instead of failing invisibly.
- Atomic write via temp file and rename, so an interrupted write cannot leave a
  source file half-replaced.
- Contained to the session workdir, checked on the realpath so a planted
  symlink cannot reach out. /mnt/data-style habit paths remap exactly as the
  python shim does.
- Under Full access absolute paths resolve, and the schema says so. Otherwise
  the model assumes it cannot reach a real checkout and falls back to the
  rewrite precisely where files are largest.
- Still prompts in auto mode: python's open(..., "w") already does, so the
  cheaper tool must not become the quiet way around that.

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

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

* Studio: harden edit_file against the cases raised in review

Six real defects, each reproduced before the fix and covered by a test.

- Receipt was bounded by diff LINES, which bounds nothing when one line is the
  whole file. A 200KB minified source returned a 400KB receipt, twice what the
  tool exists to avoid. Characters are now capped per line and over the receipt:
  the same edit returns 481 chars.
- bool("false") is True, and models emit the JSON string, so replace_all as a
  string turned the multi-match guard off and rewrote every occurrence. The two
  spellings models actually produce are mapped, anything else is refused.
- A FIFO or character device reported st_size 0 and then read forever. This path
  carries no timeout or cancel event, so the turn could not be recovered. Only
  regular files are accepted now.
- An absolute path inside a workdir that itself sits under a habit prefix
  (/workspace/repo) had its own prefix stripped and rejoined onto itself,
  resolving to /workspace/repo/repo/a.py. Paths already inside the workdir skip
  the remap; habit paths outside it still remap as before.
- Two chats sharing a project workspace could both read, both write, and the
  later rename discarded the earlier edit silently. The bytes the edit was
  computed from are compared again before the rename.
- Containment was checked once at resolve time, leaving the whole read and diff
  as a window in which a parent could be swapped for a symlink. It is rechecked
  immediately before the rename.

Left as is: an empty old_string still writes a zero-byte file. Refusing every
existing target would strand the model, since no other old_string can match an
empty file, so nothing could ever write to it. Nothing is lost with no contents,
and the mode is carried over by the write.

* Studio: give edit_file the terminal glyph, not the globe

status_for_tool reports "Editing: name" for edit_file, and toolStatusKind only
treats a "Running" prefix as local, so a file edit on this machine showed the
globe, the same badge a web search gets. It is as local as python and terminal,
so it takes the same glyph.

* Studio: bound the edit_file receipt and make creation atomic

Second review pass. Four findings, each reproduced before the fix.

- The receipt was capped on output but not on what produced it: difflib was fed
  the whole file and its generator drained into a list. replace_all on a file at
  the 16MB cap allocated ~500MB and took 1.3s to return 200 characters. difflib
  now sees only a window around the first change and the generator is consumed
  lazily. Measured on the same 16MB file: 501MB -> 48MB, 1.3s -> 0.05s; a 600KB
  file goes 48MB -> 1MB. Hunk headers are shifted back to real file lines, since
  a receipt pointing at line 3 of a 9000-line file is worse than none.
- Creation checked lexists() and then wrote, so two chats sharing a project
  workspace could both pass the check and the later write drop the earlier file.
  The absent case is now created with O_EXCL, and filling a zero-byte file goes
  through the guarded write rather than clobbering blindly.
- New files came out 0600: mkstemp makes the temp file private and copymode had
  no source to copy from. O_EXCL creation takes the usual umask-derived mode, so
  a group that reads generated files still can.
- enabled_tools in the public request schema still listed only web_search,
  python, terminal and render_html, leaving the new built-in undiscoverable to
  clients reading the OpenAPI schema, and bypass_permissions described only the
  python/terminal sandbox. Both now describe edit_file, including that Full
  access lifts its containment.

Eight new tests, 44 in the file.

* Stop the edit receipt inventing deletions, refuse non-regular targets and unpaired surrogates

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

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

* Remove the file a failed create just published

O_EXCL publishes the name before the first byte and the payload goes out
a buffer at a time, so ENOSPC or a quota partway through leaves the
bytes that fit. Reproduced with a real kernel write failure: a 117780
byte create left 4096 bytes cut mid-token, and the retry the error
message asks for is refused for ever, because an empty old_string
refuses a non-empty target and no other old_string exists for a file the
model never saw. close() can report a failure for data written earlier,
so the error can arrive after most of the file is on disk. Unlinking the
inode this call created puts the retry back on the create path, and
keeps O_EXCL rather than mkstemp, whose 0600 would ignore the umask.

* Let edit_file create an empty file

Both strings empty is the documented creation call for __init__.py, py.typed
and .gitkeep, but the identical-strings no-op check ran first and refused it,
so there was no way to write a zero-byte file. Decide creation before that
check, and stop reporting one line for a file with none.

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

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

* Trim the review commentary

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-19 07:35:11 -07:00
Michael Han
77bc10c663
Studio: price a partial GGUF by what is left to fetch (#8989)
* Studio: price a partial GGUF by what is left to fetch

The size beside a partial variant was the variant total, so continuing a
sharded download that was already 40 GB in still read "56 GB" and looked like
the whole model coming down again. That is the reading behind the reports of
the hub re-downloading models people already have.

The variants endpoint now reports download_remaining_bytes for a partial: the
plan total minus the bytes on disk a transfer can actually reuse. Reuse is
per file, so a finished shard counts and an unresumable partial does not, and
the card shows "16 GB left" instead of "56 GB".

A one-file quant therefore still reads back its full size, because that is
what continuing it costs: huggingface_hub 1.18+ refetches an interrupted file
from zero, and a single-file quant has no other file to keep.

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

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

* Studio: price local and offline partial rows too

The local and offline listings return before the hub-plan path, so their partial
rows carried no remaining figure and fell back to the full total. The on-device
card asks for exactly those (preferLocalCache), which is where a partial is most
likely to be looked at.

The worker writes its manifest before fetching anything, so those rows can be
priced from the file list that produced them. Capped at the row's own total,
since a manifest counts companions the row's size may not.

The on-device card also merges local rows over remote ones, so it now carries
the remaining figure through instead of dropping it.

* Studio: stop capping a local remainder by the shards on disk

A local listing sizes a variant by summing the shards it can see, so an early
interruption makes that total smaller than the transfer: three 2 GB shards with
one cached advertises 2 GB, and capping the remainder by it reported "2 GB left"
when 4 GB had to be fetched. Under-reporting is the one direction this figure
must never be wrong in.

The cap was there to stop a companion-inclusive remainder reading larger than
the row's own size, but no surface shows the two together: a partial row's size
chip IS the remainder. So the cap is dropped and the manifest total stands.

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

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

* Price a partial by the bytes it holds, and credit a shared blob once across case-variant repo dirs

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

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

* Follow the transfer in the remaining label while a download runs

The live overlay carried only the expected size, so a row that says
N left kept whatever the one-time variant fetch had measured, or the
full total for a download started after it. A download 90 percent
through read as though nothing had moved. The running job already
carries its own progress, so derive the remainder from it and keep the
fetched figure for every other row.

* Take both terms of the live remainder from the job

snapshot_progress nets completed_baseline_bytes out of expected_bytes
and downloaded_bytes alike, so the job's two counters are consistent
with each other and not with the catalog totals. Subtracting the job's
transfer from the larger of the two scopes added that baseline straight
back: 1 GB reused and 1 GB fetched of a 5 GB plan read 4 GB left rather
than 3 GB. The catalog total still drives the size the row reports.

* Price a pinned row against the cache root it names

A row can name a snapshot in a previous, legacy or default HF cache
root, and the request already scopes partial detection and the manifest
read to it, but the blob scan always read the active root. Wrong in both
directions: shards already in the pinned root earned no credit, and a
copy of the same blob in the active root earned credit a resume into the
pinned root cannot use, which reported less left than there is. The
docstring called counting the active root only the safe direction to be
wrong in, and it is for the first half, but not for the second.

The fixture stub also had to start honouring an explicit root; ignoring
it would have answered the active root for a pinned row and hidden the
behaviour the new tests are about.

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

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

* Keep a cancelled download on the measured remainder

A job's progress is not bytes a resume can reuse: from huggingface_hub 1.18 the
partial is process-unique, opened wb and unlinked in a finally, which is how
existing_blob_bytes already prices it. Subtracting a dead job's transfer put
1.0 GB left on a cancelled 18 GB download with all 18 GB still to fetch, so the
overlay now only prices running jobs.

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

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

* Price a fallback retry off the live transfer counter

An XET run that falls back to HTTP re-claims in the same generation with a completed_baseline_bytes recomputed from disk, so the baseline now covers every blob the XET attempt finalized and the new run reports completed_bytes 0 against a shrunken total. resolveProgressUpdate holds the previous reading through that zero, so the card kept the dead run's finalized bytes while its total moved to the retry's scope, and taking the max of the two counters subtracted 3 GB from a 0.5 GB remainder: the row read 0 B left with the transfer barely started. snapshot_progress builds downloaded_bytes as completed plus in-flight and nets the same baseline out of both, so a single reading never has completed above downloaded and the max could only ever fire on a held figure.

* Hold a retry's remainder off a held transfer reading

resolveProgressUpdate holds the previous downloadedBytes through a poll
that reported zero, and the GGUF row forwarded that held figure as the
current transfer. An XET run that falls back to HTTP re-claims in the
same generation with completed_baseline_bytes recomputed from the
finalized blobs on disk, and snapshot_progress nets that baseline out of
downloaded_bytes, completed_bytes and the total alike, so the retry's
first reading is a legitimate 0 against a shrunken total. Subtracting
the held 3 GB from the 0.5 GB that remained read "0 B left" until the
retry moved its first byte.

Carry the distinction out of resolveProgressUpdate as measuredTransfer
and skip the progress-derived remainder on a held reading, falling back
to the backend's own download_remaining_bytes the way the terminal row
already does.

* Carry the held-transfer marker across a reload

measuredTransfer marks a reading the poll did not measure, so the remainder
is not derived from a stale downloadedBytes. It was not persisted, so a
reload restored the stale bytes beside the new run's shrunken expectedBytes
with the flag reading undefined, which the selector treats as measured. That
is the '0 B left' the guard exists to stop, and on app start it lasts until
the first poll returns rather than a single tick.

Persist and restore the flag. Absent stays absent, so a record written before
this field still means never polled rather than held.

* Carry the held-transfer marker through adoption too

A restored fallback job is adopted by the next running-status probe, which
seeds the persisted byte counters but rebuilt the job without the marker.
That restored it as undefined, which reads as measured, so the remainder was
derived from the dead run's bytes again and the row read 0 B left.

The marker is a statement about those counters, so it travels with them:
seededMeasuredTransfer joins carriesOverSeed in adopt-rules, gated on the
same condition. Not carrying the seed zeroes the counters, so there is no
held figure left and undefined stays the honest answer.

* Do not credit a partial that has not been written yet

blob_bytes_present read a missing st_blocks and a present zero as the same
thing, so a partial set to its final length before its first chunk landed
fell through to st_size and was credited whole -- the row read 0 B left on a
download that had transferred nothing. Confirm the emptiness with SEEK_DATA
rather than inferring it, so a mount that never populates st_blocks keeps the
size fallback.

* Read a pre-marker record's counters as held, not as measured

A record written before measuredTransfer existed cannot say whether its byte
counters were measured, so an absent marker there is not the never-polled it
means in a current record. An upgrade landing mid-reclaim restored the dead
run's held bytes with the guard reading measured, which is the 0 B left the
marker exists to stop. Version the persisted state so the migration can tell
the two absences apart, and read only the pre-marker one conservatively.

* Trim the review commentary

---------

Co-authored-by: shimmyshimmer <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-19 07:23:25 -07:00
Lee Jackson
6fa27e20aa
Default preserve thinking on for Qwen3.8 (#9096)
---------

Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-08-19 11:22:55 -03:00
Michael Han
e3260339d8
Revert "Say why a Xet download sits at 0% until it finishes (#9159)" (#9293)
This reverts commit 3bf9bff5dd.
2026-08-19 07:02:45 -07:00
Michael Han
dc470257b6
studio: keep each chat's sampling params and system prompt with the chat (#9055)
* studio: keep each chat's sampling params and system prompt with the chat

The composer pills, permission level and retrieval controls already travel
with the thread. The sampling params and the system prompt did not, so
returning to a chat started under one prompt showed whichever prompt the
last chat had, and every chat shared one set of sliders.

Those eight join the snapshot: temperature, top P, top K, min P, the two
penalties, the system prompt and its variables. They are the only
thread-scoped settings that live under params rather than as store fields
of their own, so the read and apply paths route them through there and the
whole snapshot is still one object on the thread row.

An edit made with a chat open lands on that chat; with no chat open it
moves the installation defaults, as before. A model's own recommendation is
not an edit: both paths that apply one now say so, or loading a model while
a chat was open would pin that chat to the model's sampling.

Context and checkpoint stay out. They describe the model that loaded rather
than the conversation, and a chat restoring a budget the current model
cannot hold is the failure that exclusion exists to prevent.

* studio: keep a chat's sampling through a model load and a held edit

Three follow-ups from review on the per-chat sampling change.

A model's recommendation still landed in the live params: fromModelDefaults
only moved where the value was persisted. A chat with stored sampling ran the
model's instead, and the next unrelated edit snapshotted that over what the
chat had. setParams now puts back the keys the chat holds; one it never set
still follows the model.

The Qwen mode table is the model's recommendation, not a value the user picked,
so both paths that apply it are marked. Loading the model and toggling Think
reach the same table, so marking one and not the other pinned the chat by the
other route.

The held-edit flush read the sampling keys as store fields. They sit under
params, so it read undefined, the sanitizer dropped them, and an edit made
while the chat's snapshot was in flight was lost when the user navigated away.

* studio: persist a model's sampling defaults even when the chat pins them

Restoring the chat's values before the diff made every pinned key equal on
both sides, so it dropped out of changedParams and never reached the
installation defaults. One chat pinning temperature left every later new chat
on whatever model loaded before it.

The restore is a live-store concern only. Persistence diffs the model's own
object again, as it did before the restore existed, so the recommendation
reaches the defaults while the open chat keeps what it stored.

getChangedInferenceParams bumps the mutation versions, so it stays a single
call rather than one diff per purpose.

* studio: keep a chat's sampling across a model switch, both directions

setCheckpoint replays the destination model's remembered params itself, without
going through setParams, so the restore never ran there. An external switch has
no load after it to correct the result, so the chat adopted that model's prompt
and sampling and kept them. The restore now runs on that path too, and
persistence still reads the unrestored object so the model's own values reach
the installation defaults.

The other direction leaked the same way. rememberOutgoingModel snapshots the
live params, which inside a chat are that chat's, and on a model with no entry
it persists that snapshot whole. Filtering the incoming edit cannot undo a write
that already happened, so the outgoing snapshot is now built without the values
the open chat owns. What the model already remembered wins over the installation
copy, so stepping off a model inside a chat does not flatten a preference the
model was given outside one.

persistReplayedParams writes the installation defaults on the setCheckpoint
path, so it keeps the in-memory copy level the same way setParams does.

* Keep a chat's sampling edits through hydration, model loads and remembered-params writes

* Keep a held chat edit out of the outgoing model, and keep a default published mid-pairing

withoutActiveThreadParams returned early while a chat's read was still
out, so switching model in that window snapshotted the open chat's
temperature and system prompt into the outgoing model's shared memory.
The sibling restore path already read the held value first; this one
consulted only the override. Driving the real store put a chat's own
sentinel straight into paramsByModel, which the simulation harness
already treats as a leak.

Second, a model default published inside the pairing window was sent to
the settings route but then rolled back in memory from the pre-window
sample, so the session and the server disagreed and the stale value was
pinned onto the next chat with no snapshot. noteThreadScopedDefaults
also returned early when nothing had been applied yet, which is the
common case, so the value had nowhere to land.

* Put the installation defaults back when a chat is left mid-read

Leaving a chat while its settings read is still out writes the edits made in
it to that chat's row, but the store kept showing them, so the next chat took
them: a snapshot-less chat captured the store as the installation defaults and
was pinned with the previous chat's temperature and system prompt.

Restore the pre-window values over the committed fields, and only when the
chat is actually being left, so a retry after a failed read and a fork both
leave the edit on screen.

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

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

* Studio: tighten the per-chat inference comments

Comments only. Collapses the multi-line explanations in the thread-scoped sampling path to their shortest clear form and drops the restatements, keeping the reasons behind the pairing window, the held-edit capture and the outgoing-model filter.

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 06:24:54 -07:00
Michael Han
334db4875f
Studio: add a keyboard shortcuts page and make the existing chords rebindable (#8948)
* Studio: add a keyboard shortcuts page and make the existing chords rebindable

Settings gains a Shortcuts tab listing every shortcut, with search, a
recorder to change a chord, and controls to unassign or restore a default.

The four chords that already existed were hardcoded across four files.
They now resolve through one registry, so an edit applies without a reload.

* Keep the shortcuts tab across reloads, and decide contested chords by registry order

loadInitialTab checked a hand-written list that the new tab was missing from,
so picking it and reloading fell back to General. The list is now the source
of the SettingsTab union, so the two cannot drift.

A chord claimed by two actions is consumed by whichever window listener runs
first, which followed mount order and so varied by route. Registry order owns
it instead, and the tab names the row that loses.

* Clear rebound shortcuts on preference reset, and translate the shortcuts tab in every locale

* Lock the auth-route settings guard to its behaviour rather than one spelling

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

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

* Record nothing for a Super chord off macOS

There is nowhere to put Meta on Windows or Linux: matchesBinding
rejects an event carrying it, and the recorder wrote mod from ctrlKey
alone, so Super+Alt+K persisted and displayed as Alt+K. That assigns the
action to a chord the user did not choose, fires it on Alt+K pressed by
itself, and leaves the chord they actually pressed unable to match
anything. Returning null is the answer the recorder already gives while
only modifiers are held, so it simply keeps listening.

* Studio: render the sidebar shortcut hints from the live binding

The search tooltip and the Settings menu row hard-coded the shipped chords,
so after a rebind or a clear in the shortcuts tab they kept telling the user
to press a chord that no longer runs anything. Both now read the resolved
binding through useShortcutLabel and drop the hint when the action is
unassigned.

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

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

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-19 06:11:21 -07:00
Michael Han
0eb6b6c931
Studio: say when a scan folder cannot be read instead of showing no models (#9053)
* Studio: say when a scan folder cannot be read

A folder Unsloth is denied looks exactly like an empty one: the scan
catches the OSError, logs it, and moves on, so the model list is empty
with no reason given.

Add-time validation now opens the directory instead of trusting
os.access, which reads mode bits only and passes on folders macOS TCC or
a Windows ACL still refuses. The check runs after the denylist rules so a
denied path is never opened.

The scan keeps the error it already caught, and both scan-folders
endpoints return it as a per-folder status the dialog shows with the
setting that fixes it.

No new work on the healthy path: 0.06us per folder per scan, and the
folder list is a dict lookup with no syscalls.

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

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

* Studio: record scan folder status from the Hub inventory scan too

The folders dialog reads /api/hub/scan-folders, but the scan behind it is
the Hub inventory, not collect_local_models, so nothing was ever recorded
for it and every row stayed "ok".

Its custom-folder loop now records the same way. That alone is not enough:
the Hub's _scan_models_dir catches the OSError itself and returns an empty
list, so no exception reaches the loop. So an empty result is now the
trigger. One opendir says whether the folder is empty, gone, or refused,
and it runs only for a folder that returned no models.

A folder that found models still costs nothing, with a test that fails if
it ever touches the filesystem.

* Studio: catch denied model subdirs, and recheck when the dialog reopens

Two gaps in the folder status.

A root can list fine while every model under it is denied, on a NAS mount
or a drive owned by another user. The scanners skip an unreadable child
silently, so that arrives as the same empty list as an empty folder and
was reported as ok. The probe now also opens subdirectories, stopping at
the first refusal and capped at 64, so the denied-everything case costs
one extra open.

The row tells the user to fix permissions and reopen the dialog, but
nothing rechecked between inventory scans, so the warning stayed up after
access was restored. Listing the folders now rechecks the folders marked
bad, and only those. A healthy folder is not in the registry, so the list
still opens nothing.

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

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

* Studio: probe two levels down, and keep the tests collectable on Windows

The child probe descended one level, so <root>/<publisher>/<model> with the
model denied still reported ok: both levels above it list fine and the
scanners return nothing. It now walks two levels, depth first, on one
shared budget of 64 opens. Depth first means a denied mount is found in
three opens instead of after every publisher, and the budget bounds the
cost whatever the shape of the tree.

os.geteuid does not exist on Windows and a skipif condition is evaluated
at import, so collecting the test file there raised AttributeError before
the os.name check could skip anything. Resolved once into a shared marker,
with a test that runs the module body with geteuid removed.

* Studio: flag a folder with one denied model, and show status in the picker

A folder holding one readable model and one denied model returned the
readable one, so the scan looked successful and the denied model was
silently absent. The probe now runs whether or not models were found, and
reports "partial" in that case so the copy does not contradict the rows on
screen by claiming the folder cannot be read.

That is a real cost change on the healthy path, so it is measured rather
than claimed: 104us for a folder with 8 model dirs, 0.77ms for one with
300, capped by the same 64-open budget. The end-to-end scan stays inside
run-to-run variation, and the folder list still opens nothing unless a
folder is already marked bad. The old zero-syscall test is replaced by the
bound, which is now the guarantee that matters.

The inline model selector manages the same folders and rendered only the
path, so it shows the status too.

* Studio: stop treating an exhausted probe budget as healthy

Three fixes.

A denied directory past the open budget was reported as ok. Running out of
budget means the tail was never looked at, which is not the same as finding
it healthy, so it now returns an internal "unknown" that is never recorded
and never sent to the UI.

That alone would strand a wide folder in a warning it could never clear, so
the registry now remembers which directory refused. A recheck opens that one
directory, which settles a fixed folder in a single open no matter how wide
the folder is or where the denial sat.

A folder recorded as partial kept that status after being deleted, because
the recheck preserved partial for every non-ok probe. It now only holds
partial against a permission result, so missing and unreadable replace it.

One permission test was missing the marker that skips it as root and on
Windows, where chmod 000 does not deny.

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

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

* Probe scan folders off the event loop, and stop a vanished model or a Windows device error condemning the folder

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

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

* Probe the commit directory inside an HF snapshots folder, and stop calling a shut root partial

Two ways the dialog told the user the wrong thing. The cache layout is
models--org--name/snapshots/<commit>/, three levels under a registered
root, so a probe that stops at two never opens the one directory the
weights live in: a denied commit dir made the model vanish from the list
while the folder still reported ok, which is the silent empty case this
PR exists to remove. The extra level is bought only for a directory
named snapshots, since a blanket third level would spend the open budget
descending into diffusers component directories.

And when the root itself becomes denied, the partial branch restored
partial regardless, so a folder none of which can be read kept saying
some models in it could not be read, sending the user hunting for one
bad model. The cause the probe already returns is the discriminator: it
is the root path itself for the root's own refusal and a nested entry
path otherwise.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-19 06:10:58 -07:00
Michael Han
588405dce2
Desktop: make every drop zone take a drop again (#9036) (#9056)
* Desktop: make every drop zone take a drop again (#9036)

Tauri delivers OS file drops window-wide and suppresses the webview's own
drop events, so a zone wired only to `onDrop` does nothing in the desktop
app: no drag-over border, and the file is silently ignored.

Native drop routing (#8265) was only ever adopted by the shared image
picker and the create-project dialog. Every other file drop zone still
relied on HTML5 handlers that never fire, which is why this reads as
intermittent: the same file works when it lands on the chat, covered by
the window-wide handler, and does nothing anywhere else.

Adds `useNativeFileDrop`, which claims the native drop for an element and
returns drag-over state plus the HTML5 handlers the web build still needs,
then adopts it in the zones that were dead:

- Projects -> Sources, which also had no drag-over styling at all
- Data Recipes unstructured seed
- Diffusion training images
- Video and audio reference pickers

Documents upload by lease rather than an inline read, since the native
reader only serves media inline, so the recipe seed route now accepts
`nativePathLease` the way the RAG upload routes already do. The native
path policy accepts video containers so the reference picker can register
what it is given.

Also stops two silent discards with the same symptom: a claimed zone that
refused a drop while disabled, and compare mode disabling the window-wide
handler outright. Both now say what happened.

`native-dropzone-coverage.test.ts` walks src/ and fails if a zone reads
files from a drag payload without either claiming the native drop or
explicitly deferring to the window handler.

* Desktop: refuse a dropped model in compare too, and validate before reading

Two things the first pass got wrong.

Keeping the window-wide listener on outside single chat also handed it
model drops, so a GGUF dropped on a compare or project view would load
and replace the active model. Nothing happened there before, so that is
not a change this should be making. The refusal now covers every kind the
handler would act on, models included.

The recipe seed route also moved its extension check after the read, so a
rejected 500 MB upload was pulled into memory first. Back to validating
the filename before reading a byte, as it was.

* Desktop: size the native video cap to the largest client-side limit

64 MB sat under the reference picker's own 72 MB, so a clip the picker
accepts was refused on drop. The cap is a backstop; callers keep theirs.

* Desktop: drop the diffusion zone from this pass, and bound the native read

Two review findings, both correct.

The diffusion dataset zone accepts .bmp, .m4v, .caption and .jsonl, which
the chat attachment policy rejects outright, and .txt, which registers but
cannot be read inline. Captions beside images are the documented workflow
there, so wiring that zone to the attachment path would have uploaded the
images and silently lost the captions. It needs its own registration and
upload policy, which is more than this belongs to, so it goes back to the
picker it had.

The recipe seed route also read a dropped path in full before checking any
limit, so a multi-gigabyte local file went into backend memory before the
413. It now refuses on the stat and bounds the read by what the block has
left, in case the file grows in between.

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

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

* Desktop: keep a busy drop zone hit-testable, and size the video cap to the raw limit

A disabled seed zone carried pointer-events-none, which takes it out of
elementFromPoint, so nativeDropTargetAt could not find the target it had
just registered. The disabled message was unreachable and the drop fell
through to the window handler instead.

MAX_NATIVE_VIDEO_BYTES was set to the reference picker's 96 MiB, but that
cap bounds the data URL, not the file: the picker's own raw limit is
75497280 bytes. Rust was reading and base64-encoding up to 96 MiB, 128 MiB
across the bridge, for clips the picker then rejected.

* Match the document-refusal test to the message it now returns

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

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

* Studio: attach video to a chat, and say why when it is unavailable (#9057)

* Studio: attach video to a chat, and say why when it is unavailable

llama.cpp takes video through its OpenAI-compatible chat endpoint as an
`input_video` content part, but only when the mmproj declares video, the
binary was built with video support and ffmpeg is installed. It reports
that verdict at /props under modalities.video. Nothing about the GGUF
alone can tell us, so that is what Studio now reads.

Frontend: video joins the drop classifier and its own pending queue
beside images and audio, and a VideoAttachmentAdapter takes one clip per
message from the picker or a drop. When the model cannot take video the
adapter names all three possible causes instead of letting llama-server
refuse the request later.

Backend: video_base64 on the chat request is forwarded whole as an
input_video part, since llama-server owns the frame sampling and there is
nothing useful to transcode. has_video_input rides the same path as
has_audio_input out to the model row.

Compare mode is left out on purpose: video_base64 targets the single
loaded GGUF, so at most one side could answer. Dropping a clip there now
says so rather than ignoring the file.

Size caps line up across the three hops (64 MB in the desktop reader, in
the composer and in the route) so no hop accepts what the next refuses.

* Studio: carry the video capability through, and cover the passthrough path

Three review findings, all correct.

syncModelCapabilities took has_video_input but never copied it into the
row, and /api/models/list omits it for the active GGUF, so the adapter read
false after every load and refused video even when /props reported it. The
feature did not work in its main path.

The tool and response_format passthrough returns before the injection and
forwards an explicit field list, so a clip rode along nowhere and the model
answered without it. Refused now, the way audio already is there.

The size cap floored the base64 inflation, so a clip of exactly the size
the composer allows was refused with a 413, and the data URI header was
counted against the payload. Padded ceiling, measured after stripping.

* Studio: carry the video capability through every hop, and refuse it where it cannot be served

Three separate places map backend capability flags onto a model row and
each one dropped the video flag: the direct status adoption, the queued-run
capability Pick, and (fixed earlier) syncModelCapabilities. The adapter
reads that row, so any of them leaves video refused on a model that
supports it. Covered by a rule rather than three spot checks.

Injection lives in the GGUF branch, so an external provider or a local
transformers model answered as if no clip were attached. Both now refuse,
as does token counting, which cannot inject the frames it would need to
count. The size check also moved ahead of the automatic model switch so an
oversized clip does not evict a working model before the 413, and video now
votes in the pre-switch projector requirement alongside audio.

The video drain's read-failure toast said 'audio', inherited from the
audio drain it was cloned from.

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

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

* Studio: name the attached modality in the pre-switch refusal

Adding video to require_vision made the shared rejection reachable for a
request carrying only a clip, but its text is fixed at 'image or audio
input', so the user who attached a video was told about modalities the
request never carried. The label now follows what is attached, and defaults
to the existing wording so the image-only callers are unchanged.

* Match the document-refusal test to the message it now returns

* Send the API key on the /props readback and skip video in the context recount

The /props probe went out without an Authorization header, so under
UNSLOTH_DIRECT_STREAM=1 llama-server answered 401 and video capability
never came back. Context recount already bails on images and audio
because toOpenAIMessages has no branch for them; video has the same
property and was missing the bail, so the usage bar priced a text-only
prompt and stringified megabytes of base64 on the UI thread.

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

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

* Send a picked clip under the container its extension names

The accept list carries extensions as well as mime types because the
browser's answer is unreliable for mkv and some mov files, and the
picker takes those files on the extension. Only an empty type was being
replaced, so a clip the browser called application/octet-stream kept
that type into the attachment, and the request builder recognises a file
part only when its mimeType matches ^video/. The clip was attached, sent
and dropped, and the model answered as though nothing were there, which
is the silent drop this PR exists to remove. The table mirrors the one
in native_intents.rs, so a clip read by the desktop reader and one
picked in the browser reach the route the same way.

---------

Co-authored-by: shimmyshimmer <182633334+shimmyshimmer@users.noreply.github.com>
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>

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

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

---------

Co-authored-by: shimmyshimmer <182633334+shimmyshimmer@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-19 06:10:51 -07:00
Michael Han
f3b0425345
studio: show the model a chat was started on, and offer it back (#9081)
* studio: show the model a chat was started on, and offer it back

Every thread row has carried modelId since long before this, stamped at
creation and read only for an image-compatibility warning. So the model a chat
was started on is already recorded for chats that already exist; what was
missing was showing it and offering it back.

The chat list shows it on each single row, and a chat open on a different model
gets a one-line notice with a Switch back button. Compare rows run two models
and show none.

Deliberately an offer rather than an automatic switch. For a local model,
restoring on open would evict whatever is resident and spend a multi-gigabyte
load on clicking a row in the sidebar. The button routes through
handleCheckpointChange, the picker's own handler, so the confirmations, VRAM
checks and external-provider handling are the same ones a manual switch gets.

The notice stays quiet when it has nothing to offer: no stamp, already on that
model, or a model since deleted and no longer selectable. The row label yields
the right-hand slot to the spinner and the unread dot, and the title truncates
first, so a long model name cannot squeeze it out.

* Lift the chat model notice clear of the header instead of painting it underneath

* Reserve the notice's height in the thread viewport so it stops covering the first message

* Key the chat's model to the chat, and clear the research panel too

Clearing the stored model inside the effect is a frame late: the effect
is passive, so the first render for the incoming chat has already
committed with the outgoing chat's model, painting the wrong notice and
moving the viewport padding under it. Answering only for a matching
thread id makes that render impossible rather than brief.

The research panel needed the same offset the thread viewport got. The
notice is an opaque bar spanning the whole chat content area, the panel
column included, so it covered the first 2.25rem of the panel header:
measured 36 px of overlap over the title, the status pill and the close
button, with elementFromPoint returning the notice at all four. After
the offset the overlap is 0 and the hit tests return the intended
controls, while a chat with no notice keeps its exact previous geometry
because the variable resolves to 0px.

* Offer the switch in a chat that was started as New Chat

A chat opened as ?new=<nonce> carries no thread in the URL and keeps
none after the first send, so the notice never saw the row that send
created and switching model in a chat you had just started offered
nothing until you navigated away and reopened it. The store does learn
the id, published once the visible thread is the one being persisted,
but it still holds the previous chat's id for the first render, until
ThreadNewChatSwitch blanks it in an effect. Handing that straight over
would put the previous chat's notice on this one, so the id is taken
only after it has been seen blanked for this nonce. Before the first
send it stays an unpersisted local id and there is nothing to offer.

* Offset the canvas panel below the chat model notice

The notice is an absolute child of the chat content container, so it spans the
canvas column too. The panel's top offset was a fixed 90px, which under custom
desktop chrome left the notice covering the top 28px of the panel, including
the preview/source tabs and the close control.

Move both edges by the notice height, as the research panel already does, with
0px whenever no notice is on screen.

* Switch back on the picker's metadata, not the id alone

ChatModelNotice called handleCheckpointChange with only the model id. The
picker never does: it supplies source, isLora, isDownloaded and isGguf for
every row it offers. A Hub row or an external model does not need them
(/api/models/list carries isGguf, isExternalModelId routes the rest), but a
local or fine-tuned row is in neither, so selectModel resolved isGguf false
and the /load request dropped n_parallel, n_batch, n_ubatch and
llama_extra_args and sized the context down the transformers path.

Resolve the same metadata the picker supplies for those rows, and carry the
format for a single .gguf file out of the local inventory so it survives.
A GGUF directory stays unclaimed on purpose: that row expands to pick a
quant and a chat row records only modelId, so sending llama-server flags for
a quant nobody chose would be worse than the plain switch.

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

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

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 06:10:44 -07:00
Michael Han
2f80f11ccd
Studio: link the Read aloud TTS row to Audio and show the preview generate wait (#9093)
* Studio: link the Read aloud TTS row to Audio and show the preview generate wait

The studio TTS row named the model selector without offering a way to reach it,
and Preview showed Stop for the whole generate, which read as an idle button.

* Route the Read aloud action to Speak, not the mode Audio was left in

AudioPage stays mounted off-route and keeps its mode state, so a plain /audio
navigation could land on the transcription selector. Send the task as a mode
intent and handle a task without a model on the Audio side.

* Tighten the comments on the Read aloud TTS row changes

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

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

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 06:10:37 -07:00
oobabooga
531bc481a2
Studio: incrementally tokenize streaming code fences (#8935)
---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-19 10:08:46 -03:00
Michael Han
7b58b56228
Studio: make the partial download affordance tell the truth (#8982)
* Studio: make the partial download affordance tell the truth

The "Partial" badge's tooltip said "Click to continue", but the badge is a
status dot with no handler. On the GGUF card it sits inside the quant trigger,
so clicking it opens the quantization menu, and picking the partial quant there
only selects it. On the safetensors and dataset cards the click does nothing at
all. Every tooltip now names the button that acts.

The button itself said "Redownload" for a Xet partial, which reads as a full
refetch of a repo that may be tens of GB. It is not one: only incomplete blobs
are purged, so snapshot_download skips every shard already on disk and Xet
reuses CAS bytes through its chunk cache. The label is now "Continue", and the
tooltip says completed files are kept.

"Continue" was also promised for HTTP partials that nothing can continue.
huggingface_hub 1.18 moved the writer to a process-unique .incomplete opened
"wb", so an interrupted file is refetched from zero, and Studio pins >= 1.23 on
Python 3.10+. The transport capabilities endpoint now reports whether partials
are resumable at all, and only a partial that really is gets "Resume" or a
pause glyph on the stop control.

* Studio: lead the partial hint with the file that restarts

The unit of "kept" is the file, and most quants are one file: a 27B Q4_K_M is a
single 18 GB GGUF, so continuing its partial fetches all 18 GB again. Only a
sharded repo keeps anything, which is why the 56 GB BF16 in #8927 looked like a
resume. Leading with what survives promised something the single-file case
cannot deliver, so the restart comes first.

* Studio: decide Resume from the partial, not from the hub version

The resume wording was derived from the row's transport plus whether the
installed huggingface_hub resumes at all. Both can say yes about a partial that
cannot be reopened: a cache shared with a 1.18+ environment holds nonce-named
partials, and this repo's own pins produce that mix, since Python 3.10+ takes
hub >= 1.23 and older takes 0.36.2 against one cache. The row promised Resume
and the next attempt purged the bytes.

Rows now carry partial_resumable, the conjunction of the transport they report
and the registry's per-file check, which is what the transport-conflict dialog
already used. The label and the tooltip read that; the installed-writer
capability stays where it belongs, on the stop control for a running job, whose
partial is this machine's own.

Anything that cannot answer defaults to false, so no surface can promise a
resume it has not verified.

* Studio: scope the resume verdict to the variant that asked

is_resumable_partial takes a variant but its blob scan is repo-wide, so a
sibling quant's reopenable partial answered for a row whose own partial is
nonce-named and about to be purged. Latent until now, since every caller
passed a repo and no variant.

It now intersects the resumable blobs with the ones the variant's manifest
claims, which is what the variant transport-status endpoint already did. No
manifest means no way to scope, and an unscoped yes is the promise this
guards against, so it answers no.

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

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

* Studio: judge a companion partial by its own marker

A GGUF manifest carries the shared mmproj and MTP drafter alongside the quant's
own shards, but those blobs answer to .transport.companion, not to the variant
marker. So a companion partial left by Xet, or one with no companion marker at
all, satisfied the variant's HTTP marker and backed a Resume that
prepare_cache_for_transport purges before fetching anything.

The verdict now splits the manifest the way the worker splits it for the purge:
main hashes are vouched for by the variant marker, companions only count while
the companion marker agrees. A blob in neither set backs nothing, so an
unrecognised layout understates rather than promises.

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

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

* Scope the resume verdict to each cache entry

A repo can own several active cache directories at once: on a case-sensitive
filesystem models--Org--Model sits beside models--org--model, and
iter_active_repo_cache_dirs yields every one of them.

is_resumable_partial unioned partial hashes across all of them, then asked
read_active_transport_marker / read_active_companion_marker for the first
non-null marker found in any. So a marker in one directory could vouch for a
partial in another that prepare_cache_for_transport, which judges per entry,
was about to purge. The row promised "Resume with HTTP to keep the progress you
already have" for bytes that would not survive.

The verdict is now decided per entry, the way the purge decides, so a marker
only answers for partials sitting beside it. read_active_companion_marker had
no other caller and is gone.

* Answer the resume question from the row's own cache root

partial_resume_available scoped the transport half to the row's cache
root and then threw that root away for the resume half, so a row shown
from a remembered or custom root was judged by the active root's blobs
and the active root's manifest scope. Wrong in both directions, and
both reproduced: a legacy .incomplete under the active root made a
remembered-root row offer to keep progress the next run purges, and a
genuinely resumable variant row read as unresumable because its
manifest lives in its own root's digest scope, so the UI offered a
redownload over good bytes. The root is keyword-only and defaults to
None, so the two callers that mean the active root are unchanged.

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

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

---------

Co-authored-by: shimmyshimmer <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-19 05:37:03 -07:00
Michael Han
b8c931f9a4
Studio: size Xet download buffers from free RAM, not total (#9052)
* Studio: size Xet download buffers from free RAM, not total

hf_xet's reconstruction buffers are sized from the machine's TOTAL RAM, so a
download started while a model is loaded asks for the same multi-GB budget it
would ask for on an idle box. Those buffers are the worker's RSS, not
reclaimable page cache, so the request and the resident weights add up and the
machine swaps.

Clamp the zoo-sized budget to a quarter of free RAM by re-asking the zoo about a
smaller machine, so buffer, per-file and file count scale together. A quarter of
AVAILABLE always exceeds the zoo's eighth of TOTAL on an idle host, so the clamp
is unreachable unless RAM is genuinely held: sizing is byte-identical when there
is headroom.

Also apply the zoo's existing MIN_XET_RAM_BYTES floor to free RAM when picking a
transport, so a host too tight for even the clamped floor downloads over HTTP.

Fixes #9032

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

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

* Studio: make the free-RAM clamp reduce-only

The recompute calls xet_env_overrides directly, without the throttled flag
apply_xet_env threads through after a 429, so an un-throttled recompute could
hand back a stream ceiling that backoff had lowered. Take the smaller of the two
per key. Every derived value is monotonic in total RAM, so the result stays
coherent.

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

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

* Studio: apply the free-RAM gate where Auto is actually resolved

The Studio UI never sends transport_mode=auto. effectiveTransportMode() resolves
Auto through get_download_transport_capabilities(probe=true) and submits the
answer as an explicit xet/http, which resolve_requested_use_xet honours without
calling resolve_auto_use_xet. The gate therefore never ran on the primary flow.

Move the verdict into a shared free_ram_pressure_reason() and call it from the
probe as well, so the UI path and an API caller that sends auto agree. Probe
only, so an ordinary browse poll stays read-only and still does not load Zoo.

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

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

* Studio: let free RAM decide when health has no verdict

Both early returns in resolve_auto_use_xet skipped the gate: a zoo without
hf_xet_health, and a health probe that raises. Free RAM is read from
hf_xet_tuning, a different module, so neither says anything about whether the
machine can afford Xet right now.

Fold both into one optimistic path that still consults free RAM, and move the
registry probe's RAM read outside the health try for the same reason. A health
verdict that already demoted keeps its own reason.

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

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

* Studio: reserve free RAM across concurrently starting Xet workers

A worker allocates inside the child, after Popen returns, so free RAM does not
move until well after sizing. Four downloads starting together each read the
same untouched value and each took a quarter of it, promising the whole machine.

Sizing now subtracts what live siblings were already promised, and the
reservation is bound to the worker's pid so it frees when that worker exits. The
transport gate subtracts it too: the clamp bottoms out at Xet's floor, so enough
simultaneous workers would still add up past free RAM, and the next download is
better served by HTTP than by Xet at its minimum.

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

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

* Studio: make RAM admission and reservation one critical section

The ledger read and the reserve sat in separate critical sections, so concurrent
sizings could all read the same total before any of them wrote, which is the
overcommit the ledger exists to stop. Hold the lock across the whole
decide-and-reserve region; the recompute inside is pure arithmetic on a frozen
profile, and the RAM/disk reading stays outside it.

The earlier reservation tests started workers sequentially, which never entered
that window. The new test races four threads through it.

* Probe worker liveness without signalling on Windows, and let UNSLOTH_FORCE_XET override the free-RAM gate

* Ask the backend fresh on every Auto download start

The probe answer was cached for 30 seconds, so several Auto downloads
begun inside that window were all admitted on the same pre-reservation
verdict and each submitted an explicit transport_mode=xet, which the
start path honours without re-reading free RAM. Measured on a 32 GB box
with 5 GB free: four starts promised 4.25 GB of Xet buffer pools, one
per worker process, where a live gate admits the first and sends the
rest over HTTP. Render polls still answer from the cache; only the
probe itself has to be live, and it costs no network probe because the
health verdict is memoized upstream.

* Credit a live worker's resident memory against its reservation

The ledger charged a bound worker's whole buffer promise for its whole
lifetime, but the free-RAM reading it is subtracted from has already
dropped by whatever that worker allocated, so the same bytes went out
twice. Measured with a real child holding real touched RSS: 4.04 GB
genuinely free, reported as 2.0 GB, and the next Auto download demoted
to HTTP for room that existed. The reservation exists to bridge the
window between sizing and allocation, which is what the comment above
it says, so credit what has materialised and keep the full promise for
a worker that has not bound yet. The xet buffer is drawn through a
permit semaphore rather than preallocated, which is why resident memory
is the right measure of how much of the promise is already spent.

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

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

---------

Co-authored-by: shimmyshimmer <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 05:36:30 -07:00
Daniel Han
915fa5473c
fix(studio): refuse a hand-set context unified memory cannot hold (#9172)
* fix(studio): refuse a hand-set context unified memory cannot hold

The Metal branch of load_model already works out the largest context that
fits, but only Auto was ever moved to it. An explicit context was passed
through verbatim on the theory that "--fit on" is a backstop. It is not one
here: --fit flexes -ngl to spill layers to the host, and on unified memory
the host is the same pool, so the spill frees nothing and the launch
over-commits. A discrete GPU answers that with an out-of-memory error, a Mac
has nothing to fall back on and takes the whole system down.

Gate the explicit request on the ceiling the branch already computes and
refuse above it, naming the largest context that fits so the user can act on
it. Two deliberate exemptions: the 4096 fallback used when KV cannot be sized
is a guess rather than a measurement, so it never refuses, and a manual load
with a fixed layer count is the user taking the memory budget over, which is
how the other two Metal context guards already treat it.
UNSLOTH_ALLOW_METAL_CTX_OVERCOMMIT=1 opts out.

The refusal is raised after the placement block, not inside it: the broad
except there turns any raise into "GPU selection failed" and restores the
original request, which is the over-commit being refused.

The slider warning said the model "may use system RAM", which is sound advice
on a PC and describes something that does not exist on unified memory. Say
what actually happens there instead.

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

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

* Exempt the virtualised Metal device, and correct why --fit is not enough

Two findings from simulating the guard across [Windows, Linux, WSL, Mac] x
[NVIDIA, AMD, CPU only] against the real load_model.

A virtualised Mac was being refused. Studio pins that whole load to CPU behind
--device none, because offloaded layers on a paravirtual Metal device produce
corrupt output, so it allocates no GPU memory at all and a GPU working-set
budget is the wrong yardstick for it. The refusal would have broken loads that
work today on a Mac VM, and on the macOS Actions runners, and the message would
have described hardware the launch never touches. Host RAM is the real limit
there and _host_offload_shortfall_message already prices it. The neighbouring
_caller_owns_budget did not cover this: it is read off the request, so it stays
False for the Auto load the pin rewrote to manual/0.

The stated reason --fit could not be relied on was wrong. It said --fit only
moves layers to the host, which frees nothing on unified memory. In fact
llama.cpp does reduce an explicit context: common.h defaults
fit_params_min_ctx to 4096, and only "-c 0" raises it to UINT32_MAX to disable
reduction. The real problem is what it decides from. ggml-metal's free-memory
report comes off the device's recommendedMaxWorkingSetSize, which knows nothing
about Studio's own resident gigabyte or two, other running apps, or the iogpu
wired limit that is the figure being blown, and _apple_metal_memory_budget_bytes
exists precisely because of that gap. When the estimate is optimistic the
request stands, the launch over-commits wired memory, and wired pages are not
reclaimable by Jetsam, so the driver faults and the machine panics.

Tests for both, plus the pass-through -c spellings, what a refused reload
costs, and that neither route rewrite (path redaction, the unsupported-model
relabel) mangles the message.

The two slider warnings no longer promise the load will be refused, since the
virtualised case above is exempt. They say what is true everywhere on unified
memory and what to do about it.

* Apply the repo formatters

* Measure the ceiling the guard refuses against, and word the warning from unified memory

Three holes, two of them in the refusal itself.

The fit hands back its own min_ctx floor when nothing fits, not a smaller
answer, so a Mac with room for no tested context returned 4096 with a
footprint over budget. That landed in the arm meant for "weights alone
exceed the budget", left the measured ceiling unset, and skipped the refusal
for every explicit context, on exactly the machine where llama.cpp's --fit
has nothing left to give either (it will not price below fit_params_min_ctx,
which is the same 4096). Re-price from the search's own 256 alignment step
before concluding nothing was measured; the weights-over-budget arm returns
the request untouched for any min_ctx, so the two states are told apart by
whether the re-priced answer is smaller.

The fit was also sized through the model's native length, so the ceiling
could never exceed it and any request past it was refused as an over-commit
even with the memory to hold it. Nothing clamps a request to the native
length on the way in: the Extra Arguments box takes a raw --ctx-size and its
own placeholder suggests --rope-scaling yarn, and llama.cpp builds the
context at the full -c, capping only the per-slot value afterwards. Re-price
through the request when it is larger, and adopt that only when the budget
vouches for it. max_available_ctx stays sized from the native length, so the
slider bound the UI reads does not move.

Both were invisible to the tests because every case stubbed the fit with a
lambda that has neither a floor nor a native bound. The stub is now optional.

Third: device_type is "mac" for every Darwin host, so the new warning wording
reached Intel Macs, where a discrete GPU spills to system RAM exactly as the
old wording said. /api/health now reports apple_silicon alongside
device_type, on the same gate the Metal budget uses, and the two warnings
read that instead. Absent means false, which is the old wording, so nothing
regresses where it was already right.

* Name an encoding on the route read, and keep the log line out of the comment

Both found by staging CI on windows-latest, and both are this branch's doing.

routes/inference.py carries non-ASCII (the DeepSeek tool-call tokens), and
Path.read_text() defaults to cp1252 on Windows, so the new route-message test
died on a UnicodeDecodeError there. tests/test_source_read_encoding.py catches
this class on Linux too and was already failing. Pass encoding = "utf-8", which
is the convention the rest of the test tree follows.

The comment above the refusal quoted the placement handler's own log line, and
test_tp_vision_regression string-searches load_model's source for that phrase to
assert statement ordering. Quoting it moved the match ahead of the try body and
failed the ordering assert. Reworded around it, with a note saying why.

* Refuse when nothing fits at all, and stop the health test depending on file order

The over-budget arm has three states, not two, and the third was still slipping
through. When the fit shrinks, the weights themselves fit, and that is the
signal separating a real measurement from the weights-alone case. If the floor
it shrank to does not fit either, then nothing fits: there is no number to
lower to and every explicit context over-commits. That was being treated as
unmeasured, so those requests reached llama-server on a host where all of them
are the crash this guard exists to stop.

Refuse them, with a message that names no ceiling, because inventing one would
send the user to a number that hits the same wall. Auto keeps the 4096 floor it
has always launched at here: changing what Auto does is a larger claim than
this guard makes. The manual-layers and virtualised-device exemptions still
apply. Weights alone over budget still never refuses, and the discriminator is
now explicit rather than folded into one condition.

Separately, the new health test could not survive a full-suite run. Several
files in this tree install a plain `loggers` module and a minimal `structlog`
into sys.modules, and main imports `loggers.config`, which needs the real
package and real structlog. Whichever file sorted first decided whether this
one could import main. It now restores both before importing, and skips rather
than fails if structlog is genuinely absent. Pre-existing pattern, and the same
reason the MLX-repair health test fails in a full-suite run.

* Stop the guard tests reading the real machine's free memory

The virtualised-device cases passed on a 16 GB runner and failed on a 7 GB
macos-14 one, which reads as a Mac-specific bug and is not one. The paravirtual
placement is the only one in this harness that reports child_has_no_gpu, which
is what gets it past the host-RAM guard's empty-pool early return; that guard
then priced the model against the real host, because _launch stubs
_apu_ram_shortfall_message but never stubbed _available_system_memory_mib.

Host-memory dependent, not OS dependent: a real 8 GB Mac fails the same way.
Stub it to None, which is both helpers' documented way of saying the caller
does not know the host's memory and must not refuse on it. Verified the file
now passes with available memory forced to 3000, 800 and 200 MiB.

* Use an unsignalable pid in the fake process, and put back the modules the health test swaps

The fake llama-server the guard tests inject is not inert data. load_model
adopts whatever pid it is handed into the process-lifetime registry, and
teardown signals that pid's process group, so a low placeholder is a live
signal target rather than decoration. 4194303 is one below pid_max: validly
shaped, names no process, so the identity check drops it and it can never be
signalled. The comment says why, because the next person to copy this stub
will otherwise reach for a small number too.

The health test also displaced the loggers and structlog stubs other files in
this tree install and never put them back, which left a global mutation behind
for whatever ran next in the same pytest process. It now restores exactly what
it removed, on the skip path as well as the success path.

* Read the weights-only state off the budget, not off two fits agreeing

Both probes are bounded by the same target, so on a model whose native length
is already at or under the search's 256 alignment step they return the same
number for a reason that has nothing to do with the weights. Inferring "the fit
priced nothing" from that agreement left both verdicts unset and let every
explicit context through on a host where none of them fit. Reproduced at
native == 256 exactly: an explicit 8192 launched where it should have been
refused. Also reachable whenever the GGUF carries no context length, since the
request itself then becomes the target.

Ask the budget instead: weights alone over it is the state the fit cannot price,
and that is a property of the weights and the budget, not of two return values
tying. Same verdict as before everywhere the old comparison was right.

Auto is unchanged, checked rather than assumed: swept the weights band either
side of the boundary on both this tree and the merge base and the emitted
context matches at every point.

* Re-probe below the fit floor when extending a short-native model

The above-native extension probe runs at the fit helper's 4096 minimum. When
the model's native length is under that floor and the machine has room for
something in between, the probe hands back a non-fitting 4096, the footprint
check discards it, and the refusal falls back to naming the native-sized cap.

On a 2048-native model with room for 3072 the guard then contradicts itself:
a request for 3072 launches, and a request for 8192 is refused with "the
largest that fits is 2,048", sending the user to a third less context than
the machine holds.

Re-price under the floor when the floored result fails validation, which is
the pass the cap above already makes for the same reason. Guarded on both
sides, so it can only raise a ceiling the budget vouches for.

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

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

* Publish the extended Metal ceiling, not just refuse against it

The above-native extension probe raised _apple_measured_ceiling but left
max_available_ctx at the model's native length. That value is published as
max_context_length, and both amber warnings fire when the loaded context
exceeds it, so a 131,072-token request on a 32,768-native model loaded fine
and then arrived with "context length exceeds what fits in unified memory
(32,768 tokens)" against a context this branch had just measured and allowed.

Copy the accepted ceiling into the published bound. The fit is bounded by the
request, so this raises the bound to the context that actually loaded and no
further, and a refusal (which accepts no ceiling at the request) leaves it
where the cap put it.

* Tighten comments in the Metal unified memory context guard

Collapse the multi line rationales added by this PR to their shortest form
that still carries the reason: why the fit helper's 4096 is a floor and not a
measurement, why the refusal is raised outside the placement try/except, why
the paravirtual CPU path is exempt, and why max_available_ctx is published.
Comments, docstrings and whitespace only, no behaviour change.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 05:31:54 -07:00
Michael Han
489caae23f
Studio: confirm an Audio model swap instead of dead-ending on the backend 409 (#9092)
* Studio: confirm an Audio model swap instead of dead-ending on the backend 409

Audio called /load and /unload directly, so the 409 the backend raises when chats
are still generating arrived as an error toast with nothing to act on. Route both
through the same confirmation Chat uses and force from the answer.

* Hold Chat's lifecycle gate across the question, and defer a load confirmed after Audio hides

Without the gate a queue can materialize while the dialog is open, outside the
snapshot the answer was given for. pendingTtsLoad is also still null while the
dialog is open, so the deactivation effect could not abort a load confirmed from
a page that had gone away; queue it for the activation replay instead.

* Stop Chat queues only once the Audio load request is going out

loadModel prepares the stored Hugging Face token before it sends /load, and
returns without sending when that token is invalid and the user picks replace
or dismisses the warning. Cancelling the accepted sends and queued prompts
before that call discarded them for a swap that never happened, leaving the
previous model resident and the work gone. Move both cancellations into the
existing onRequestStart callback, which runs only once the request is issued.

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-19 05:31:13 -07:00
Daniel Han
65bdc91a1a
Repair the tests main is currently red on (#9192)
* Give the mmproj fallback import its file extension

node --experimental-strip-types resolves relative specifiers literally,
so the extensionless import added in #9173 fails at import time and
takes queued-model-capabilities.test.ts down with it. Before that commit
this module had only import type lines, which are erased, so it carried
no runtime relative import at all and the test passed. 55 other modules
already spell the extension.

* Studio: repair the repo tests #9173 left red

The mmproj fallback landing added a mmprojFallbackMessage import to
chat-adapter.ts. Two repo tests read that file's source:

- test_chat_autoload_failure_gate slices autoLoadSmallestModel out of the
  adapter and runs it, and its own guard fails the whole file when the slice
  references a name the harness preamble does not define. Add the stub.
- test_model_picker_contracts pinned the success toast's description as the
  literal 'description: cpuFallbackReason'. The mmproj branch now sits ahead
  of the CPU one, so pin the description expression instead of which fallback
  is tested first.

71 failures on main, none of them a real regression.

* Studio: repair the backend test #9173 left red

test_tensor_split_abort_raises_early_to_layer_fallback pins the order of
two branches in load_model by their source offsets. #9173 renamed the
variable handed to _strip_mmproj_args, so the find() of the old spelling
returned -1 and the ordering assert compared against it. Pin the call
itself and fail loudly when it is no longer reachable.

* Stop the Windows chat UI smoke racing the route Suspense fallback

exercise_permission_mode_controls asserts the permission pill visible on
the 5s expect default, immediately after the composer mounts. The composer
can mount and then drop back behind the root Suspense fallback while the
route chunk loads, so the assertion lands on 'Loading...' and gives up.

Reproduced on main at aa32c1861: same step, same line, aria snapshot
'- text: Loading...'. It is Windows-only in practice because the mac smoke
wraps the script in a 3-attempt retry and the Windows one does not, so a
slow 4-vCPU runner surfaces the race raw.

Wait the pill out on the same budget the composer already uses.

* Wait the permission pill out at every reload, not just the first

The macOS smoke hit the same Suspense race at the second assertion, which
the first commit did not cover. Four of the six assertions in this block
follow page.reload(wait_until = domcontentloaded), which returns long
before the lazy chat chunk resolves, so asserting on expect's 5s default
races it by construction rather than only on a slow runner.

Route all six through one helper that waits on the composer's budget.
Seen on both the Windows and macOS smokes as 'Aria snapshot: - text:
Loading...' at playwright_chat_ui.py:135 and :189.

* Give the playwright stub every name the harnesses import

The CPU test job has no playwright, so tests/studio/test_heavy_thread_measurement_integrity.py
puts a stub `playwright.sync_api` into sys.modules at collection time rather than skipping the
harness arithmetic along with the browser. That stub defined one name, `sync_playwright`, which
is all its own harness imports.

sys.modules is session-wide, so the stub is not read only by the file that installs it. Three of
the four harnesses import `sync_playwright` alone and were satisfied by it; playwright_strip_ansi_smoke
also imports `Page` and `expect`, and got

    ImportError: cannot import name 'Page' from 'playwright.sync_api' (unknown location)

from a stub two files away. `(unknown location)` is the stub itself: a bare ModuleType has no
__file__. The two tests that import that harness, test_the_ansi_dump_survives_a_vite_server_that_is_
still_talking and test_an_empty_smoke_base_url_means_unset[playwright_strip_ansi_smoke], failed on
it. pytest.importorskip("playwright") does not save them, because by then the stub package is
importable.

This is pre-existing on main and predates this branch, which never touched either file. It was
masked: the same job was failing with 71 other errors, which this PR fixes, so these two were
never the reported cause. They belong here because this is the PR that makes the job green.

The stub now answers any public name with a callable that raises, so a harness can satisfy its
imports without quietly measuring a browser that is not there, and adding an import to a harness
cannot break a different file again. Dunders still raise AttributeError, since pytest and inspect
probe those and answering them makes the stub look like a package. The install probe also imports
off the submodule now: a partial install leaves `playwright` importable while `playwright.sync_api`
resolves to an empty namespace, which fails the same way.

Verified by blocking playwright in a subprocess to reproduce the CPU job: 2 failed, 104 passed
before, 106 passed after, with the two files alone skipping cleanly when the stub is not collected.

* Stub the fork-count endpoint the heavy-thread smoke actually requests

The heavy-thread browser smoke fails its own harness check on this branch:

  HARNESS-BROKEN chromium at 25000 chars let 2 /api/ requests reach the
  network during the measured actions; the timings include a round trip
  per request

Both leaked requests are GET /api/chat/threads/__LOCALID_.../forks, one during
seeding and two more inside the measured actions. The smoke page answers a small
allowlist of endpoints in-page so no round trip lands in a timed region, and the
fork-count entry in that allowlist still matches the per-message endpoint,
/threads/{id}/messages/{id}/forks. #8992 replaced it with one per-thread request
built by getThreadForkCounts, and the allowlist was not moved with it, so every
fork-count GET went to the dev server. fork-count-store refreshes on
CHAT_HISTORY_UPDATED_EVENT, which the delete action fires, which is why two of
them land inside the measurement rather than only at load.

Match the URL the client builds and answer the body the endpoint returns:
getThreadForkCounts reads data.counts into a Map, so {"counts":{}} is "no
message has forks" and renders no badge. The fixture is unchanged by this: the
census at both sizes is identical to the failing run, 3676 and 14285 DOM nodes,
20 and 80 messages, 3216 and 12804 highlighted tokens. Only the round trips are
gone.

This has been broken repo-wide since #8992 and invisible. On every other open PR
the earlier Unit tests step in this job fails on a broken import, so Install
Chromium and every Browser smoke step is skipped; this branch is the first job
in which they run at all. Compare step outcomes on #9081, Unit tests failure and
all smokes skipped, with this branch, Unit tests success and the smokes running.

Two things so the next drift costs less. The stray-request failure now names the
first three distinct URLs, because it fires in CI where nobody can attach a
listener afterwards, and finding this one meant re-running the smoke locally with
a print statement patched in. And a unit test now checks the allowlist against
the URL chat-api.ts builds, rather than against a string someone remembered to
update, so the same drift fails in Dev-server lifecycle tests instead of waiting
for a job where the browser smokes get to run.

The self-check itself is untouched: still zero tolerated strays, still failing
the run.

Verified locally on chromium at 25000 and 100000 chars: before, 2 stray requests
at each size and exit 1; after, 0 stray requests at each size, 9 stubbed rather
than 6, and exit 0. The three smokes the aborted job skipped, settings tab
panels, a settings panel that cannot load, and chat stream pacing, all pass
locally as well, so nothing further is hiding behind this one.
2026-08-19 05:03:46 -07:00
alkinun
41b93a9169
Allow unlimited Deep Research time budgets (#9078)
* Allow unlimited Deep Research time budgets

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

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

* Bound unlimited research request setup

* Validate unlimited research timeout settings

* Handle unavailable research timeout storage

* Preserve research stream idle timeout

* Update Deep Research dialog contract

* Bound the unlimited path and restore the timeout floor for PR #9078

An unlimited budget removed the wall clock that used to backstop a queued
request, so a backend that sent one queue notice and went silent held the run
open until the user cancelled. Bound the gap between notices with the same
value the unlimited read timeout already uses; each notice refreshes it, so
genuine queueing is still not charged.

- Clamp the model-load and model-switch waits: with the 3600s ceiling gone, a
  large finite budget could make the poll loop wait for years.
- Give modelTimeoutSeconds a readable one-year ceiling instead of float max, so
  the 400 no longer quotes a 309-digit number, and keep the 10s floor for
  positive values with 0 as the only sentinel below it.
- Bound the same field in the persisted chat settings.
- Offer the default minutes when a dialog switches off No limit, rather than 1.

* Report an unlimited stall by name instead of racing HTTPX for PR #9078

With no wall clock behind an unlimited request, HTTPX's read timeout and the
first-output/idle guards were set to the same value, so which one fired was a
coin flip. HTTPX won often enough in an end-to-end run against a real SSE
server, and it raises ReadTimeout with an empty message, so the run failed with
a blank error instead of naming the stall.

Give the read timeout headroom so the named guards expire first, and convert a
bare ReadTimeout that still gets through into the matching stall error.

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

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

* Bound the research timeout where it is set for PR #9078

Two validators disagreed with the run route, so a value could be persisted that
made every later Deep Research run fail to start with nothing pointing at the
setting that caused it.

- The composer's minutes field had no ceiling and the mirror bound was
  MAX_SAFE_INTEGER, while ChatSettingsPayload and the run route both cap at one
  year, so a large entry was accepted, dropped from the settings patch, and then
  rejected on every run. Bound it where it is set: the input, the store setter
  and loader, and the mirror now share the backend ceiling.
- The settings payload took 1 through 9, which the run route rejects. Refuse
  those there too, so a persisted value is one the run route will accept.

* Refuse a sub-floor research timeout in the frontend too for PR #9078

The settings payload now rejects 1 through 9, but the mirror bound and the store
validator still accepted them, so a stale or hand-edited local value would
hydrate, be sent unchanged, and 400 every run start while its own patch was also
rejected.

sanitizeBoundedNumber takes an optional minPositive so a sentinel below the floor
stays legal while the gap above it does not, which is the shape this field needs
and not a special case in the caller.

* Clamp an over-cap typed budget instead of resetting it for PR #9078

The max attribute does not stop a typed value from reaching the save handler,
which passed it on to a setter that substitutes the 900 second default for
anything over the cap. So someone asking for a very long run saved what looked
like their limit and got a fifteen minute one, which is the failure this PR
exists to remove.

Clamp to the documented maximum at save time.

* Bound queue gaps by the configured admission heartbeat for PR #9078

* Tighten the comments for PR #9078

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

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

* Name the Deep Research budget by its per-request scope for PR #9078

* Reject a boolean budget instead of reading it as unlimited for PR #9078

* Reject a boolean settings timeout and keep an untouched budget exact for PR #9078

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-19 05:01:10 -07:00
Daniel Han
df8fb6a03b
Say both load fallbacks when both fire, and repair the two suites that guard it (#9189)
* Repair the two chat auto-load suites that #9173 left red on main

#9173 added a vision-projector fallback to the auto-load success toast. Both
of the suites that read that code broke on it, and both were green on the
commit immediately before (54b6ca4c3).

72 tests, in two different shapes:

1. tests/studio/test_chat_autoload_failure_gate.py, 71 failures. The harness
   slices autoLoadSmallestModel verbatim out of chat-adapter.ts and runs it, so
   anything the slice references must exist in PREAMBLE. mmprojFallbackMessage
   did not, which is a bare ReferenceError inside the retry loop; the loop
   catches it and scores it as a failed load, so every scenario fails as a
   wrong-model assertion. The file's own guard caught this and named the symbol,
   which is what it was written for after #7699 did the same thing.

   Stubbed as a function of the reason rather than a copy of the real
   three-message record. The value only reaches `options.description`, and these
   scenarios assert on which model loaded, never on toast copy, so copying
   user-facing strings in here would give them a second home to drift from.

2. tests/studio/test_model_picker_contracts.py, 1 failure. It asserted the
   literal `description: cpuFallbackReason`, and the mmproj branch went in front
   of it. The property held; only the spelling moved. Its own comment records
   this happening once already, when the CPU-fallback branch first appeared, so
   it now pins the property: the description varies on both fallback reasons and
   still has an undefined arm for the ordinary path.

   Scoped to the description EXPRESSION, not the whole helper. `cpuFallbackReason`
   is also a parameter name in the signature above, so a substring test over the
   block stays green with the CPU branch deleted outright -- the first cut of this
   check did exactly that, and mutation caught it.

Three mutations verified red: the description no longer driven by any fallback
reason, the CPU branch dropped, and the mmproj branch dropped.

4223 passed, 4 skipped.

* Say both fallbacks when both fire, not just the projector one

Both load paths wrote the toast description as

  mmprojFallbackReason ? mmprojMessage : cpuFallbackReason ? cpuMessage : undefined

so whenever both reasons are set the CPU-fallback sentence is dropped. The user
is told "loaded without vision" and never told the model is running on the CPU,
which reads as a deliberate, explained degradation rather than an unaccelerated
session.

The combination is reachable. On a CPU-fallback replay llama_cpp.py preserves
_cpu_fallback_reason (it clears it only when not _replaying_cpu_fallback) and
resets _mmproj_fallback_reason so the projector can fail again inside that same
launch. A low VRAM machine whose Vulkan backend crashed is exactly where the
projector then falls back too.

loadFallbackNotice() in mmproj-fallback.ts is now the single composition of the
title suffix, the description and the degraded flag, and both call sites delegate
to it. CPU_FALLBACK_MESSAGE moves there as well, so the two paths cannot describe
the same condition differently again.

Tests: four combined-case cases in mmproj-fallback.test.ts, verified red against
the shipped nested ternary. test_model_picker_contracts.py now asserts the call
sites delegate and pass both reasons rather than matching the old inline ternary,
and test_chat_autoload_failure_gate.py's stub mirrors the composition so a call
site dropping a reason stays detectable.

* Repin the CPU-fallback toast test to behaviour, and unbreak the queued-capabilities test

Two frontend suites were red.

auto-load-cpu-fallback-toast.test.ts matched the warn-vs-success choice and the
message text as substrings of showAutoLoadSuccess. Both moved into
loadFallbackNotice, which is the single definition the explicit-load path now
shares, so the match went stale. Matching the inline form again would go red on a
refactor that changes nothing a user can see, and would stay green if only one of
the two load paths kept the behaviour. It now calls loadFallbackNotice and asserts
the verdict, and separately asserts the call site delegates to it.

queued-model-capabilities.test.ts was red on main before this branch. #9173 added
`import { isTextOnlyMmprojFallback } from "./mmproj-fallback"` to
image-input-support.ts, which the test imports statically. Extensionless is the
right form -- 2314 of the 2367 relative imports under src/ are written that way,
and vite and tsconfig's "bundler" mode resolve them -- but the bare node loader
does not, and a static import resolves before any registration can run. The test
now registers the bundler resolver and imports dynamically, which is what
mmproj-fallback.test.ts already does for the same module.

Both files reformatted by biome; regex literals hoisted out of the test bodies
for useTopLevelRegex.

Full frontend suite: 3734 pass, 0 fail. typecheck clean. The 4 biome errors in
chat-adapter.ts and use-chat-model-runtime.ts are byte-identical on origin/main.

* Order the split-axis abort against the mmproj strip, not against its argument

test_tensor_split_abort_raises_early_to_layer_fallback has been red on main
since #9173, which renamed the text-only strip's argument from _last_spawn_cmd
to _vision_gpu_cmd. That rename is right: the strip should read the vision GPU
command rather than whatever was spawned last, and #9173 refreshes
_last_spawn_cmd from the result immediately after. The test was pinned to the
old argument name, so a rename with no behavioural content took it down.

The failure also misreported itself. `assert raise_idx < src.find(needle)` reads
as an ordering check but is two claims at once, and when the landmark is gone it
fails with "assert 249423 < -1" -- which says the ordering broke, when what
happened is that the landmark moved. Each landmark is now required to exist
before it is ordered, and says so.

The strip is matched on the call rather than on what is passed to it. What this
test is about is that the abort raises BEFORE the projector is discarded (#6659);
which command the strip reads from is that code's own business.

Checked both ways: removing the strip call from load_model goes red with a
message naming the missing landmark, and renaming the argument again stays green.

* Name the endpoints when the heavy-thread harness catches a stray request

The harness records every /api/ URL issued during a measured action, then keeps
only the count, so the failure reads

  let 2 /api/ requests reach the network during the measured actions

and stops there. It says an interaction paid for a round trip without saying
which one, and the reader has to bisect the frontend to learn what the harness
already knew and discarded.

It now reports the endpoints. Deduplicated and capped at eight, because the case
this instrument exists to catch is a request issued once per message, which would
otherwise print hundreds of copies of one line.

This is why it surfaced now: the step has not run on main since #9173, whose unit
test break fails earlier in the same job and short-circuits it. It was last green
at 54b6ca4c3. With the unit tests repaired on this branch the job reaches the
step again, and the first thing it needed to say was the one thing it did not.

* Skip the playwright harness tests on the module they need, not the package

Five tests guard themselves with `pytest.importorskip("playwright")` and then
import a harness that does `from playwright.sync_api import Page`. On the Repo
tests (CPU) runner the top-level name resolves as a namespace directory with no
sync_api inside it, so the guard passes and the import dies with

  ImportError: cannot import name 'Page' from 'playwright.sync_api' (unknown location)

A skip condition reported as a failure, on every branch, for as long as that
runner stays that way. It is red on #9202 and #9213 too, neither of which touches
any of this.

One of the two files already said what the guard was really for: "importing a
harness pulls in playwright.sync_api". It now checks that.

* Stop the fork-count store asking the server about threads it has never seen

Two fixes, both found by the heavy-thread smoke once it could name what it caught.

The smoke reported "let 2 /api/ requests reach the network during the measured
actions" and, with the endpoints now printed, they were

  POST /api/chat/threads/__LOCALID_lsQbsDZ/forks

A `__LOCALID_` thread has no server record, so that request can only 404, and
getThreadForkCounts already maps 404 to the empty map the entry starts as. It is
a round trip whose answer is known before it is sent.

Not a rounding error. A new chat is in exactly that state, and this store
refreshes on CHAT_HISTORY_UPDATED_EVENT, which fires once per streaming chunk, so
the first reply in a new chat paid one useless request per debounce window for as
long as it streamed. #8992 added the store to stop the chat getting slower as a
thread fills; excluding threads the server has never seen is the same intent.
thread-ids.ts already had the predicate.

Two tests: a local thread must not fetch on subscribe or on a burst of history
events, and a saved thread on screen beside it must still refresh -- the guard has
to be per thread, not a global off switch. They import the real
isAssistantLocalThreadId rather than restating the prefix, so the rule under test
cannot drift from the app's. Both go red with the guard removed.

Second fix, same job: the playwright skip guard. The previous commit moved it from
"playwright" to "playwright.sync_api" and it still failed, because sync_api
resolves as a namespace package on that runner too. Only the symbol the harnesses
import distinguishes a usable install, so the guard now checks for Page the way
the harness does. Verified both ways against a Page-less sync_api: it skips, and
it still proceeds when Page is there.

Frontend suite 3789 pass, typecheck clean, tests/studio 4237 pass.

* Bound and retry the Playwright browser install so a stall is not a silent 30 minutes

This step stalls. Three times in one day it sat in apt's download loop until the
job's 30-minute timeout killed it, while the sibling shards finished the whole job
in 4 to 9 minutes. Twice on #9202 and once on #9189, always the same step.

The cost is out of proportion to the cause. GitHub scores a job timeout as
"cancelled" rather than a failure, prints no reason, and skips every step after
it, so the chat shard reported nothing about the chat surface for what both times
turned out to be an infrastructure hiccup that cleared on a plain re-run of the
same commit.

A per-attempt `timeout` turns the stall into a failure instead of a silent wait,
and the retry is what actually recovers. The healthy time is about 2 minutes, so 8
per attempt is 4x headroom and a merely slow mirror will not trip it.
timeout-minutes bounds the pair in case `timeout` is outlived by an unkillable
child. Same reasoning, and the same wording, as the bounded prime-hf step in
studio-mac-ui-smoke.yml.

Both install steps in this file, since ui-smoke and ui-indicator run the identical
command. Left alone on mac and windows: neither passes --with-deps, so neither has
the apt phase this is about, and neither has been observed to stall.

* Make the Playwright install retry able to actually recover

The bound added in the previous commit worked: the stall became an 8m37s step
FAILURE with a complete log instead of a silent 30-minute cancellation, and the
log named the cause on the first try.

It also showed the retry could not work. playwright shells out to apt-get as root,
so terminating the python parent leaves that child alive holding the lock, and
attempt 2 died two seconds later with

  E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 4578 (apt-get)

A retry that cannot succeed is worse than no retry: it buries the real reason
under a second, different failure. Attempt 2 now waits for the lock to clear, up
to two minutes, and only then takes it -- the holder is our own orphan and the
runner is a throwaway.

Two smaller things the same log exposed. --kill-after was missing, so a process
that ignores SIGTERM would have been waited on forever inside the step bound. And
the warning said "did not finish within 8 minutes" about a two-second exit, which
sends the next reader looking for a stall that never happened; it now separates
timeout's 124/137 from playwright refusing outright, and prints the status.

timeout-minutes 18 to 22 to cover two 8-minute attempts plus the lock wait, still
inside the job's 30.
2026-08-18 20:58:30 -07:00
Maheswar Kumar
6e5360ca91
show the local model's context window before the first token count (#8882)
Some checks failed
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Unsloth export capability / capability (ubuntu-latest) (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Unsloth UI CI / Chat UI Tests (chat) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (extra) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (picker) (push) Waiting to run
Unsloth UI CI / Loaded-models indicator (cross-browser) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 units, no-VS resolve, VC++ round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Application Control CI / installer survives a denied unsloth.exe (push) Waiting to run
Workflow trigger lint / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Frontend CI / Frontend unit tests (Windows) (push) Waiting to run
Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Studio UI + API + Update CI / Chat UI, API and Update Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (macos) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (banner) (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection + VC++ round-trip (VS 2026) (push) Waiting to run
Mac Studio GGUF CI / GGUF inference smoke (API, tools, vision) (push) Has been cancelled
* show the local model's context window before the first token count

The chat header rendered the context bar only when `contextUsage` was set, so a resident GGUF
showed nothing until a token count landed. The recount that produces one is best-effort: it
stands down for images, audio, Deep Research, a pending RAG turn and a busy backend, and a chat
that has never been sent has no usage at all. In every one of those cases the context length was
invisible exactly while the user was picking a model to fit a prompt into (#8867).

The bar now renders as soon as the resident model's window is known, showing `— / 32.8k` until a
count arrives. An uncounted chat is kept distinct from a counted zero: `percent` stays null, so
no fill, no percentage and no near-limit warning are drawn. Reporting an unmeasured prompt as 0%
of the window would be the same misreport in a new place.

`hasKnownContextWindow` gates on the resident checkpoint rather than the picked one, so a load in
flight, an evicted model and an API selection all keep their windows off the header.
`deriveContextUsageBar` moves the prop-to-text derivation out of the component so the node suite
can drive it directly.

* stop counting an empty chat's template scaffolding as context usage

* decide the empty-prompt count where the tool policy is known

* keep a passthrough tool catalog out of the empty-prompt refusal

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-18 22:43:36 +02:00
Daniel Han
b0843382d2
Studio: make the chat thread stop getting slower as it fills (#8992)
* Fix two module resolution failures in the frontend test suite

* Fix the third Windows-only path failure in the frontend test suite

* Measure how the chat thread's interaction cost grows with message count

Studio's chat UI is reported as sluggish on Windows 11 and worsening as the
thread fills, while token generation is unaffected. That shape says the cost is
per-message renderer work, so the thing to establish first is the curve.

New smoke page mounts the real Thread against a synthetic local runtime, seeded
to N messages, each carrying prose plus one code fence plus one KaTeX block so
Streamdown, Shiki and KaTeX all pay their per-message price. No backend, no
auth, no router.

New harness runs four scripted actions at N in {10, 50, 200, 500} under 6x CDP
CPU throttling: one keystroke into the composer, one scroll gesture, one message
action menu opened and closed, and one message delete. Each is bracketed by
Performance.getMetrics, which separates the two families of cost: work that
grows because layout is uncontained lands in LayoutDuration, work that grows
because a listener or an export is O(messages) lands in TaskDuration alone.

It measures, it does not gate. There are no timing budgets: it prints the table
and exits 0 unless the harness itself broke. Budgets belong in a later change,
set from numbers taken on real hardware. What it does fail on is measuring
nothing, since that is the failure mode that reads as good news: a seed that did
not render, a menu that never opened, a delete that deleted nothing, a scroll
that did not move, or four columns that do not rise with N.

Measured on this tree, N=10 to N=500: menu open+close 1309ms to 33199ms,
delete 438ms to 8491ms, keystroke median 86ms to 268ms.

Every metric recorded reaches the printed table, and the harness contract test
now enforces that mechanically by parsing the recorded keys out of the source
and requiring each one in the table.

CDP CPU throttling and longtask are Chromium-only, so Firefox and WebKit runs of
this file are correctness checks and not performance ones.

* Stop the thread-weight harness charging its own cost to the app

A review of the first commit found four ways the measurement could look clean
while reporting something other than the app, and two of them were forging part
of the curve. All were reproduced before being fixed.

The local runtime does have a remote id. It synthesises `__LOCALID_...`, which is
truthy, so the per-message fork-count GET fires after all: seeding 20 messages
issued 10 requests, against a comment claiming nothing reached the network. They
were being answered by a Playwright route handler, so each one paused the
renderer for a round trip to another process, once per assistant message. The
page now answers them itself, before anything mounts, and the harness fails if a
single request escapes during a measured action.

Closing the menu was timed from after the Escape dispatch. Radix dismisses
synchronously inside it, so the layer teardown, focus restore and re-render --
the O(messages) fan-out this issue is about -- were excluded from the number
meant to capture them.

Every timing carries a ~33ms floor, since a double rAF cannot resolve faster than
two vsync intervals, and CPU throttling does not move it. An action that never
happened therefore reported ~33ms, which reads as a plausible measurement rather
than as a failure. The floor is now measured per N, printed, subtracted before
every growth ratio, and a keystroke at or under it fails the run.

The keystroke check read the DOM value back, which is what the harness itself
wrote. It now compares against the runtime's own composer state, so a keystroke
that reaches the textarea but not React is caught.

Also removed from the timed regions: a per-frame document-wide querySelector in
both poll loops, replaced by a MutationObserver flag and an isConnected check; a
counts() call per frame in the seed gate; an animated scrollIntoView still in
flight when the menu window opened; and a console warning per action-bar render,
by giving the page the router its useNavigate calls expect. Long tasks are now
read after a yield, since the observer delivers on a later task and the tail
entry was being dropped.

Corrected curve, N=10 to N=500, floors removed where they apply: menu open+close
1021ms to 33591ms, delete 297ms to 8563ms, keystroke 48ms to 283ms, scroll worst
frame 5ms to 126ms. Layout stays flat and tiny throughout; the growth is in
style recalc and task time.

* Stop mounting the assistant action bar for every message

At rest the full assistant action bar was mounted under every assistant
message. Each one carries around eight tooltips, and every tooltip holds a
useSyncExternalStore subscription to the shared modal-layer store, which
re-walks its ancestors reading style.pointerEvents whenever Radix puts the
body on the modal layer. A 500-message thread therefore mounted 250 bars and
1503 tooltip triggers, and every menu open fanned out across all of them.

autohide unmounts rather than hides (ActionBarRoot returns null on the hidden
status), so passing it removes the nodes and the subscriptions together. The
user bar has always done this.

Not unconditionally "always", though: this bar carries the only Stop reading
control, which is why it already passes hideWhenRunning={!speaking} and why
DeleteMessageButton guards the same case. With "always", moving the pointer
off a message being read aloud would take that control away. At most one
message speaks at a time, so exempting it costs nothing.

Measured with tests/studio/playwright_thread_weight.py at 6x CPU throttle,
before -> after, at 500 messages:

    action bars           250 -> 0
    tooltip triggers     1503 -> 3
    DOM nodes           56332 -> 41082
    delete ms          8595.8 -> 3642.7
    scroll worst frame  159.5 -> 87.3
    menu open+close ms 33624.5 -> 25279.3
    keystroke median ms  316.2 -> 241.0

Note what did not move: menu style recalc, 23725.8 -> 22567.7 ms. A 27% cut
in DOM buys 5% there, so the bar is not what makes that number grow. It is the
document-wide invalidation from Radix writing pointer-events onto the body,
and it is still the dominant cost at large N.

The index.css comment is corrected in passing: it justified forcing
content-visibility: visible on every code block with "thread length is
bounded", which is the assumption this issue disproves. The rule is kept for
the flicker it was really fixing.

* Studio chat: one fork-count subscription per thread, not one per message

The fork badge registered its own CHAT_HISTORY_UPDATED_EVENT listener and issued
its own GET, and it is mounted once per message. A delete on a 200-message thread
therefore fired 200 requests before anything could repaint, and streaming raises
that event once per chunk.

Badges now share one debounced subscription per thread and one request that
returns every fork count of that thread, so the cost is flat in thread length.

* Studio chat: derive research-message ownership once per thread revision

useOwnsResearchMessage exported the whole thread from inside a per-message render
body, so one render pass over N messages exported N times and inspected N*N items.
Streaming re-renders the thread once per chunk, so that pass is hot.

The answer is a property of the thread revision, so derive it once for the message
list every message in the pass already shares. Measured on a synthetic thread: 200
exports and 0.80ms per pass becomes 1 export and 0.014ms; at 1000 messages 16.4ms
becomes 0.02ms.

* Studio chat: stop deep-cloning the thread on every delete and every save

exportedItemToRecord ran JSON.parse(JSON.stringify(...)) over every message's
content and attachments on its way to a PUT that serializes the same records
again, and syncExportedRepositoryToBackend ensured the thread row that
syncStoredChatMessages already ensures, so every save paid for GET /threads/{id}
twice.

The parts are replaced rather than mutated, so a copy of the list is snapshot
enough and the bytes on the wire are identical (asserted in the new test).
Measured on a 200-message thread of ~4KB messages: the record step drops from
1.17ms to 0.015ms, and a delete makes one thread-row read instead of two.

* Studio chat: open the message action menu non-modally

A modal Radix menu writes pointer-events:none on <body>. That is an inherited
property, so every open and close invalidates style for the whole document, and
on a long thread the recalc is the bulk of the cost. Non-modal never writes it.

Also teaches the harness the difference between a cost that was removed and a
page that never mounted one, so the after-tree does not read as broken.

* Pin the message action menu to the non-modal layer

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

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

* Drop the #8980 content this branch no longer needs

* Print the hovered trigger count and keep the modal layer load-bearing in the verdict

The contract test caught both: a metric recorded but not printed, and the verdict
no longer reading body_pointer_events_while_open after the non-modal fix removed the
old check. The layer is now compared ACROSS N instead, since either mode is
legitimate but mixing them means the columns measure different mechanisms.

* Stop the rapid-submit settle wait measuring the action bar instead of the reply

This branch autohides the assistant action bar, and that turned the last
wait of the rapid-submit step into a 7-of-7 failure on Windows CI. The wait
is not what the step proves, and the clause that broke was not measuring
what it claimed.

innerText of a [data-role=assistant] root spans the whole subtree, and the
action bar sits inside it, so 'every reply has non-empty innerText' was
satisfied by button labels regardless of what the model returned.
Instrumented at that point on the CI runners, two runs on this branch's
merge base read:

  content=[0, 0]   innerText=[73, 73]   clause held, BOTH replies empty
  content=[0, 19]  innerText=[73, 89]   clause held, first reply empty

gemma-3-270m-it answers 'Reply with exactly: rapid-first' with an empty
completion in 3 of 8 sampled runs, on the merge base as much as here, and
the clause held every time. So the empty reply is the model, is pre-existing,
and was simply masked. With the bar autohidden the subtree is content only,
and the same empty completion now fails.

Dropped rather than repointed at the content element: an empty completion is
the model's behaviour, so a content assertion would be flakier than what it
replaces. What is left is exactly the settle this wait is for, two bubbles,
nothing streaming, nothing queued. The behaviour the step exists to prove,
that a 100 ms follow-up queues behind a held first turn, is state.queueSeen
above and is untouched.

Test-only. No Studio code changes, so the menu open+close and nodes-at-rest
wins are unaffected. Verified on Windows CI at this branch's head: 4 of 4
Chat UI Tests jobs green with this change, against 7 of 7 red without it.

* Validate the delete measurement at every size, not just the last

* Keep the thread's fork counts across the autohidden badges

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

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

* Exempt the thread-weight harness from the CI-coverage check

* Keep the newest reply's action bar in the tab order

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

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

* Load the crypto polyfill on the thread-weight smoke page

The page this branch adds was the only one in studio/frontend without
<script src="/crypto-boot.js"></script> in its head, so
crypto-uuid-boot.test.ts fails with "smoke-thread-weight.html must load
/crypto-boot.js".

The rule is not cosmetic: the polyfill has to run before the module entry
or crypto.randomUUID is missing on the older WebViews Desktop embeds, and
a smoke page without it measures a page that differs from production in
the one respect the harness is meant to hold constant.

Reproduced on this branch and verified: the named assertion fails before
the change and the file's four tests pass after it.

* Debounce the fork count refresh instead of throttling it

onHistoryUpdated returned while a timer existed, which is a leading edge
throttle rather than a debounce. Streaming raises the history event once per
chunk, so the timer expired mid stream and the next chunk armed another one,
costing a whole thread fork count fetch every 300ms for as long as the reply
ran. Fork counts cannot change during generation, so all of those were waste.

Clear and reschedule on every event, as the sidebar refresh already does.

The existing burst test fired all 20 events inside one window, where a
throttle and a debounce behave identically, which is why this survived. The
new case spreads the events across the window like a real stream: 20 chunks
cost 10 refetches before this change and none after, with one refresh in the
quiet window that follows.

* Reveal the action bar on focus, not only on hover

Unmounting the bar on every message but the newest took its controls out of
the tab order, and there was no non-pointer way to bring them back, so Copy,
Edit, Refresh, Delete, Read aloud and More were unreachable by keyboard or
screen reader on every older reply. Deferred rendering is fine while the user
can still ask for what is deferred, and tabbing is asking.

Measured on the thread weight smoke page at 20 messages, older reply: the
accessibility tree exposed no bar controls before this change and still none
after focus entered the message; it now gains Copy, Refresh, Delete, Edit
response, Read aloud and More. Three tabs from an older reply used to walk
its two code fence buttons and step to the next message without ever
entering a bar; the same walk now lands on the bar's Copy.

The library has no focus path, so this drives its own isHovering flag from
focus within the message root. Two writers share that flag, so a pointer
leaving while focus is inside re-asserts it from a listener registered in an
effect, which runs after the primitive's own mouseleave in the same dispatch
and so never renders the intermediate false that would unmount the element
holding focus. The clear is a one frame watchdog reading activeElement
rather than a relatedTarget test, because relatedTarget is null both for
browser chrome and for a portal, and no focusout fires at all when the
focused element is removed, which is how the menu closes.

At rest this stays at one mounted bar of ten, and at one again after a focus
round trip, so the weight this branch is here to remove is unchanged: 1950
DOM nodes and 9 tooltip triggers, the same as before.

* Give a plain prose reply a way into the tab order

The focus reveal only fires once focus is inside the message, and a reply
whose body is plain prose contains nothing focusable after autohide unmounts
its action bar. The earlier measurement of two focusable controls per message
was an artefact of a fixture where every reply carried a code fence, and
Streamdown ships one Copy button per fence. Seeded with a prose only reply it
is zero, and Tab walks straight past the message into the next reply, so
Copy, Edit, Refresh, Delete, Read aloud and More stay unreachable.

tabIndex on the message root rather than a visually hidden button: it adds no
DOM node, which matters for a branch that exists to cut per message weight,
and it draws nothing at rest. The app's own focus-visible rule gives it the
same 1px keyboard indicator every other focusable container already has, and
focus-visible means a mouse click still draws nothing.

Measured at rest, unchanged: one action bar of ten, 1950 DOM nodes, 9 tooltip
triggers. Outline is none with neither focus nor hover, and none after a
click. The cost is one extra tab stop per assistant message, which is the
price of the controls being reachable at all.

The fixture gains an opt in plain prose variant so the existing weight
measurements keep the exact thread they had.

* Bound how long a fork change can wait behind an unrelated stream

CHAT_HISTORY_UPDATED_EVENT fires once per streaming chunk, and the fork-count refresh was a pure
trailing-edge debounce, so a reply running in a background thread reset the timer on every chunk.
Deleting a fork from the sidebar while looking at its parent changes the displayed count, and the
refresh was postponed until the unrelated stream went quiet, which on a long or queued run is
minutes. That is starvation, not slowness.

The event is a bare Event with no detail and six other consumers, so telling fork changes apart
from chunks means changing a contract well outside this store. FORK_COUNT_REFRESH_MAX_WAIT_MS
bounds the wait inside it instead: a second timer, started by the first event of a burst and
deliberately not restarted by the ones after it, races the debounce, and whichever fires first
cancels the other. A second timer rather than a Date.now() deadline so it runs off the same clock
as the debounce and is testable without a fake Date.

2000ms because the bound costs one whole-thread fetch per window while a stream runs. At the
300ms debounce that is the per-chunk traffic this store exists to remove; at 2000 it is under a
sixth of it, and only while something is streaming.

The existing continuous-stream test asserted ZERO mid-stream fetches, which is the behaviour the
review flagged, so it now measures the price of the bound instead of claiming there is none: it
pins the count against both what the ceiling allows and what a leading-edge throttle would have
cost, so a regression in either direction is a failure.

Four assertions were made to fail on their own broken tree before being kept: the ceiling
removed, the ceiling restarted per chunk so it never expires, the losing timer left uncancelled
when the other fires, and the ceiling left running past unsubscribe. That last one was vacuous at
first, since the entries map is empty after unsubscribe and a leaked timer refreshes nothing; it
now subscribes a second thread inside the ceiling window, which is both observable and the case
that actually costs a user a request.

* Scope the popup lookup to the action bar

The watchdog treated any expanded descendant as this message's open menu. Reasoning cards and
tool-fallback cards are Radix CollapsibleTriggers and render aria-expanded=true for as long as
the reader leaves them open, which is the resting state of a message whose tool output has been
expanded. decide() therefore found a popup every frame, rescheduled itself every frame, held
focusWithinRef and the synthetic hover set, and left the bar mounted indefinitely, at the cost of
a DOM query per frame per such message. Scoped to .aui-assistant-action-bar-root, which is where
the trigger the hook has to hand focus back to actually lives.

Proving this took three attempts and the first two were wrong, which is worth recording because
the failure was in the test rather than in the fix.

isHovering has two writers. This hook is one; assistant-ui's own MessagePrimitive.Root mouseleave
handler is the other, and it writes false directly. So a phase that reveals the bar by HOVERING
and then moves the pointer away sees the bar unmount on both trees, because the library unmounted
it. On the broken tree the watchdog genuinely spins forever, and the bar still goes away. An
assertion on the mounted bar count cannot attribute that outcome to this branch, and C2 passed on
the fixed and broken trees alike.

The phase now keeps the pointer off the message entirely and reveals the bar by focus, which the
tabIndex on the message root makes possible. With no mouseleave to fire, focus is the only writer
and the bar's fate is decided by the watchdog alone. C2 is green on the fixed tree and red under
--break widepopup, 1 bar still mounted and held indefinitely. Two guards sit in front of it: one
asserts the pointer really is off the message, the other that the bar really was mounted and
focused, so the phase fails loudly rather than passing vacuously if either precondition breaks.

Also fixes a pre-existing crash the sweep was hiding. Under --break eagerclear the bar is gone by
the time A8 runs, and a bare more.focus() on an undefined element threw a Playwright TypeError
that killed the process before phases P, B and C ever ran, so the break reported fewer reds than
it earns and ended in a traceback rather than a red result. It is more?.focus() now and the
trigger's absence is folded into A8's condition, so eagerclear completes and A8 goes red on its
own merits.

Full sweep: head 14/23, notabindex 19/23, restring 21/23, focusring 22/23, leakflag 21/23,
noreassert 22/23, eagerclear 21/23, widepopup 22/23 with C2 the only red. thread.tsx checksummed
before and after all eight runs, identical every time.

* Reveal the action bar from the backward traversal too

The tabIndex on the message root only worked going FORWARD. A container is reached before its own
descendants, so Shift+Tab arriving from the message below landed on the last tabbable thing in the
message, and with the bar unmounted that is the root, which sits BEFORE the bar in DOM order.
Focusing it mounted the controls and the next Shift+Tab then stepped straight past them to the
previous message. Copy, Edit, Delete and More were reachable going forward and unreachable going
backward, which is worse than being unreachable outright, because the forward pass makes it look
solved.

A sentinel span after the bar is what makes the backward pass land inside the message: focus stops
there, the bar mounts, and the next Shift+Tab goes into the last control rather than out. It is
deliberately NOT a focus redirect to that control, which would trap the forward pass in a loop
between the last button and the sentinel. It carries no onFocus of its own because React's onFocus
is focusin and already bubbles to the root, and no role, because it performs no action; the
aria-label is what stops it being an unannounced stop.

It DOES cost one DOM node per assistant message, and this branch is about per-message weight, so
that is asserted rather than absorbed: the at-rest guard now requires exactly 1950 baseline nodes
plus one sentinel per reply and nothing else, measured 1960 for 10 replies, with the sentinel count
checked separately so the extra nodes are attributed rather than tolerated.

Three assertions, each proven red. nosentinel takes D2 and D3 red while D1 stays green, which is
precisely the reported asymmetry: focus still enters the reply, it just skips the bar. D1 says
something weaker, so it needs noentry, which removes the sentinel and the tabIndex together and
leaves nothing in the message reachable at all; D1, D2 and D3 all go red there.

Full sweep, 26 assertions: fixed 26/26, nosentinel 23/26, noentry 18/26, notabindex 24/26, head
15/26, widepopup 25/26 with C2 alone, eagerclear 21/26, noreassert 25/26, leakflag 24/26, restring
24/26, focusring 25/26. thread.tsx checksummed before and after every run, identical throughout.

* Draw a focus indicator on the backward reveal sentinel

The shared soft-outline rule is :where(div, main, section, aside, ul, ol):focus-visible, which
never matched a span, so the sentinel was a real tab stop that drew nothing: Shift+Tab into a
message made focus visibly disappear for one stop before the next press reached the action bar.
That is a focus-visible failure, not a cosmetic one.

The element stays 0x0 and the ring is drawn by outline-offset. Outlines take no part in layout,
so the indicator appears without shifting the message, which giving the span dimensions on focus
would have done. Still nothing at rest, and :focus-visible means a mouse click draws nothing
either.

Measured: with keyboard focus the ring spans 14px against the UA default's 2px on a zero-sized
span, which is the difference between an indicator and no indicator. D4 asserts the SPAN of the
drawn ring rather than merely that an outline style exists, because the broken tree still reports
outline-style auto and would satisfy a presence check while showing nothing.

Proven red by --break blindsentinel, which removes the indicator rule and leaves the tab stop
itself intact, so only D4 fails.

* Stop tracking the generated Studio test database

.studio-test-root/studio.db is written at test time by
tests/studio/install/test_selection_logic.py, which points storage_roots.studio_root
at that path. It is a mutable SQLite runtime database, not a fixture: nothing
reads it, any test run or Studio start rewrites it and dirties the checkout, a
later accidental commit could capture real local chat or settings data, and it
puts 221 KB into every clone while exercising nothing.

It was not in the tree deliberately. It arrived in the merge commit here because
that commit was staged with `git add -A` after running the suite, and no
gitignore rule covered the path. Main does not track it.

Untracked and ignored, and the file is left on disk since creating it is normal.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-18 06:19:05 -07:00
Nilay
01cf3728cd
Studio: show MCP server display names instead of internal ids (#8557)
* Show MCP server display names in tool cards, status text, and exports

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

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

* Stamp MCP display names on provisional tool events

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

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

* Import mcp-tool-name directly and drop unused provenance import

* Stamp MCP names on unrun tool cards and index the display label

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

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

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

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

* Tighten the new code comments for PR #8557

* Shorten the new comments for the MCP display name change

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-18 05:45:33 -07:00
Nilay
8696c5fb3c
Studio: remember last used model in the backend, not per-browser (#8587)
* Studio: remember last used model in the backend, not per-browser

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

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

* Pass auto-load abort signal through readLastLocalModelLoad

* Last-model setting: path-length variant bound, legacy shadow write on failed PUT

* Write the legacy shadow record synchronously before the PUT

* Last-model shadow: keep v1 loadedAt, reconcile pending shadow on read

* Last-model setting: order pending shadows by loaded_at across surfaces

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

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

* Last-model setting: re-sync newer shadows even on identity match

* Last-model setting: match timestamp before clearing the pending marker

* Last-model setting: ignore stale timestamped writes on the backend

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

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

* Last-model setting: clamp client stamps, reconcile pre-upgrade tab writes

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

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

* Last-model setting: translate client stamps into the server clock frame

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

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

* Last-model setting: only a pending shadow outranks an unstamped backend record

* Fix biome-ignore comment indent

* Scope the remembered model per subject for PR #8587

The endpoint authenticates a subject but stored one shared row, so on a
multi-user install whoever loaded a model last decided what everyone else
auto-loaded. Key the record by a hash of the subject. A subject with no row of
its own still falls back to the pre-scoping shared row, so an existing install
keeps the model it already remembered and only stops following it once that
subject writes its own.

Rethrow an aborted read by error name rather than instanceof DOMException:
authFetch goes through the Tauri network-retry wrapper, and an abort resurfaced
as a plain Error would be swallowed and answered with a stale shadow record.

Adds the frontend coverage this file had none of (old backend 404, shadow
newer/older, pendingSync, day-scale clock skew both ways, corrupt shadow, the
gguf-variant rule, abort), plus a test pinning a known limitation: client_now is
stamped at send, so a PUT delayed in a retry is re-dated to arrival and an older
load can outrank a newer one. Ordering that correctly needs a server-issued
revision rather than a client timestamp.

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

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

* Tighten the new code comments for PR #8587

* Trim the new comments in PR #8587

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-08-18 05:45:18 -07:00
Nilay
1274395567
Studio: bulk chat management multi-select move/pin/archive/export/delete (#8745)
* Studio: add Manage chats bulk actions in Settings > Data

* Format two test files to satisfy the pre-commit ruff hook

* Use main's setPinned and scope the select-all label for PR #8745

main landed setPinned(ids, pinned) on the pinned-chats store while this branch
carried an equivalent setManyPinned, which is what the merge conflicted on.
Drop the duplicate and call main's, whose no-op early return also avoids a
pointless write when nothing changes.

The header checkbox toggles the visible page (20 rows behind Show more) but
its label read "Select all chats" next to a total count, so on a longer list
it promised more than it selected. Label it for what it does, and cover the
bulk pin/unpin ordering.

* Tighten the new code comments for PR #8745

* Anchor shift-select on the chat id and honor the delete-files preference for PR #8745

The shift-click anchor was a row index held in a ref, but useChatSidebarItems
re-sorts by updatedAt on every history update, so a chat streaming in the
background can move rows between the two clicks and the saved index then names
a different chat. The range swept from there could archive or delete chats the
user never selected. Anchor on the chat id and resolve it at click time, reusing
rangeBetween, which the sidebar already uses and which falls back to the clicked
row alone when the anchor is gone.

Bulk delete also called deleteChatItems without the args object, so the API got
delete_files:false and every selected chat kept its sandbox even with 'Always
delete files' on. Pass the preference through, as the sidebar and single-chat
paths do.

* Carry the row project when opening a chat from Manage chats for PR #8745

* Tighten comments in the Manage chats view and its tests

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-18 05:42:33 -07:00
Wasim Yousef Said
077593612c
Fix low VRAM mmproj fallback in Studio (#9173)
* fix(studio): recover mmproj startup on CPU

* fix(studio): report mmproj fallback in chat

* test(studio): cover mmproj fallback messaging

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

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

* fix(studio): refine mmproj recovery guards

* fix(studio): complete mmproj reload recovery

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-18 13:58:59 +02:00
Daniel Han
58403dd001
Studio: measure where a heavy thread stalls, across engines and thread size (#9016)
* Studio: measure where a heavy thread stalls, as a curve over thread content

Users report Studio and Desktop going sluggish after long generations with
code cells and text. That is a statement about content volume, so the new
harness varies characters of thread content rather than message count, and
the fixture carries the mix the report names: prose, large code fences, tool
calls with collapsible output, code-execution result panes, HTML and canvas
artifacts, and inline images.

The primary metrics are DOM-observable and wall-clock, because Unsloth
Desktop is a Tauri webview and not Chromium. PerformanceObserver accepts
type longtask on WebKit 26.5 and Firefox 153 without throwing and then never
fires, so support is read from supportedEntryTypes; CDP counters are recorded
alongside and labelled Chromium-only.

* Settle the highlighter between repetitions and tolerate constant engine chatter

Re-opening the thread throws away every highlighted fence, so repetitions 2
and 3 were measuring a thread that was still building itself: on Chromium at
300K the scroll gesture read 667ms on the first repetition and 1100ms on the
two after it, and the difference was the re-highlighting.

Firefox 153 emits exactly two scroll-anchoring notices per run at every size.
A warning count that grows with the thread still fails; a constant one does
not, or the harness could never report a Gecko number.

* Measure time to settle from the start of the action, and stop crediting floored metrics

Measured from the end of the gesture, time to settle reads ~50ms at every
size on every engine, because the answer is then three frames, which is the
minimum the loop can return. From the start of the action it is what a user
waits.

A count that goes 0 to 4 has answered the question and counts as
discriminating. A floored timing that is zero or negative at the smallest
size has not: it says the action resolves inside one frame there, which is a
metric with no room to move.

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

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

* Record a crashed cell instead of losing the whole matrix

A WebKit page that ran out of memory at 300K on a loaded machine took eight
good measurements down with it. The cell is now recorded as crashed, the run
continues, and the verdict still fails on it.

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

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

* Say precisely what the Long Tasks observer does off Chromium

* Say how to bound a wedged engine, having measured that nothing in-process can

On a macos-14 runner Chromium finished all three sizes in 90 seconds and then
Playwright's WebKit wedged at the smallest size and never came back, which
cost the whole matrix. page.evaluate and browser.new_page have no timeout,
and SIGALRM does not help: the sync API blocks the main thread inside a
greenlet, so the exception lands in the driver and the caller never resumes.
The process boundary is the only bound that works, so the docstring says to
drive one engine per invocation under an external timeout.

* macOS runners have no timeout(1), so give the portable bound instead

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

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

* Load the crypto polyfill on the heavy-thread smoke page

Same defect as the thread-weight page: the smoke page this branch adds was
missing <script src="/crypto-boot.js"></script>, so
crypto-uuid-boot.test.ts fails with "smoke-heavy-thread.html must load
/crypto-boot.js".

It matters more here than on a normal smoke page. This harness is what the
perf numbers are measured on, and a page that lacks the polyfill differs
from production in exactly the kind of way that makes a measurement mean
something other than it claims.

Verified: the named assertion fails before the change, and all four tests
in the file pass after it.

* Measure what the labels say in the heavy-thread harness

Every repetition deleted a message and nothing put it back, so repetitions 2
and 3 ran on a smaller thread than the census recorded before the loop. An
instrumented run at 25K read 20, 19, 18 messages at the start of each
repetition; the smoke page now exposes restore() and it reads 20, 20, 20.
One cycle is 20 messages against 10 content kinds, so at 25K those deletions
were taking a whole kind each time.

The re-open window closed on three calm frames, which held 7 rAF samples for
an action taking up to 1.4s, and the leftover highlighting was absorbed by
the untimed gate at the top of the next repetition. It now settles on no long
frame and no new highlighted token for a grace period, and reports the time
of the last activity so the grace is not added to both ends of every ratio.
The token probe is polled rather than read per frame because it is a
document-wide query whose cost would otherwise grow with the signal.

The recorder decided ownership from a shared running flag, so a callback
scheduled by the previous action ran once more under the next one and both
loops appended to the same array. It carries a generation token now.

Both settle() calls in the menu script compare a MutationObserver flag before
the observer microtask has run, so each waits out a full double rAF. The
growth axis carries a count of those floors instead of a flag, and the menu
total carries two.

median() dropped None, so a repetition where the menu never opened was
averaged away and the null checks downstream never saw it; it now returns
None if any repetition did, and a key that was null throughout stays present.
A scroll, jump or re-open that never settled is a harness failure rather than
an axis reading not recorded.

Expanding the tool panes after waiting for the highlighter mounted two fresh
unhighlighted fences per cycle whose work landed in the keystroke window, the
next thing timed. Seeding and repetitions share one build_fixture() now.

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

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

* Run the measurement integrity tests in CI

The 22 tests added with the harness fixes were registered in no workflow, so
they ran nowhere. A test that cannot fail is not a guard, and these are the
ones that keep a timed-out repetition from being published as a median of
three.

* Stub the fork count with a real zero, and only that endpoint

getForkCount returns data.count and the badge's guard is count <= 0. An empty
object body makes that undefined, and undefined <= 0 is false, so the badge
rendered on every assistant message with a title reading undefined forks from
this message. Measured at 25000 chars: 10 badges and 4031 DOM nodes before,
0 badges and 3981 after. That is DOM in proportion to thread size, added to
the axis this harness exists to measure.

The stub also matched every url containing /api/, so any other request a
measured interaction made was resolved locally before Playwright emitted it
and measure_cell's listener never counted it. The harness could report zero
stray API requests while fanning out, which is the thing it claims to catch.
It matches the fork count endpoint alone now.

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

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

* Answer the requests the harness provokes from an allowlist

Narrowing the fetch stub to the fork-count endpoint was right, and it broke
the run: the delete action persists through syncExportedRepositoryToBackend
whenever remoteId is truthy, which the synthetic __LOCALID_ id always is, so
three requests reached the wire inside the delete measurement and the stray
check failed every size. CI showed it as HARNESS-BROKEN, 5 requests, at both
25000 and 100000 chars.

The stub is an explicit allowlist now rather than either a blanket match or a
single endpoint. Each entry answers a request the harness itself provokes,
with the body that endpoint really returns, so no round trip lands in a timed
region, and anything unlisted still goes to the network and trips the counter.

Narrowing it is also what made two requests visible that the blanket match had
been hiding since the beginning: re-opening a thread asks for the project list
and the knowledge bases. Those are app fan-out rather than fixture upkeep, so
they are stubbed to keep the network out of the reopen window but counted and
printed as stubbed api requests. Answering a request must not delete it from
the record.

The fork-count guard now pins the body to its own allowlist entry rather than
scanning the whole file, since another entry legitimately answers with an
empty object and a file-wide check would fail on it while saying nothing about
fork counts.

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

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

* Add seedCompactTail and gapMetrics for the viewport gap measurement

#9058 measures the empty band below the last mounted row and needs two things
this harness did not expose. Landing them here rather than having that branch
duplicate 767 lines of an unmerged harness.

seedCompactTail(targetChars, tailMessages) builds the heavy fixture with the
same buildThread call seed() makes, then appends N one-word messages, so the
first mount commit lands entirely on compact rows. That is the worst case for
a fixed size initial window. Census parity is the point: seedCompactTail
(25000, 16) reports 36 messages against seed(25000)'s 20, a tail of exactly
16, with every other count unchanged.

gapMetrics() reports the band below the last row, measured against the
viewport's bottom edge rather than scrollHeight, so the viewport's own bottom
spacer counts as the gap it always was and the caller subtracts spacerHeight
to get the part the mount window owns. Computed any other way the numbers
stop being comparable across sizes. Both degenerate returns are kept: no
viewport gives ok 0 alone, and a viewport with no rows gives ok 0 with
mountedRows and clientHeight.

The contract test is separate from the rest because what it protects is on
another branch: #9058's probe preflights for these exact names and keys, so a
rename here breaks a probe nothing in this repo would otherwise exercise.

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

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

* Gate the tool expand on a count that a closed thread fails

The wait after expandTools() read collapsibleOutputs, which is the Radix
collapsible CONTENT ELEMENT. Radix keeps that element in the tree for its
collapse animation, so it is present whether the card is open or shut.
Measured at 25000 chars before any expandTools() call: collapsibleOutputs 2
of the 2 expected while codeExecutionPanes was 0, and after expanding,
collapsibleOutputs was still 2. The gate could not fail, so the ordering that
build_fixture exists to guarantee, expand and then wait for the highlighter,
was never enforced.

It reads codeExecutionPanes now, the pane's own pre, which is 0 collapsed and
22 expanded at 300000 chars on all three engines. The comment on
collapsibleOutputs claimed Radix mounts the content only while open; that is
measurably false and it now says what the count really is, with a warning not
to gate on it.

Also records what the timed windows scan, since two review items asked. Each
window scans a fixed number of times rather than once per frame, and the cost
was measured rather than argued: the re-open makes two messageCount passes,
0.4ms of a 2292ms re-open at 300000 chars and 0.0ms of 363ms at 25000, and the
menu scans four times for 2.7ms of 3208ms against 0.3ms of 375ms. The share is
0.017 and 0.08 percent, the same at both ends of the axis.

* Run the gap contract in frontend CI

I added test_heavy_thread_gap_contract.py and registered it in neither the
path filter nor the pytest step, so the guard that keeps #9058's probe working
ran nowhere. That is the third time this round something was added that could
not fail, and this one was mine, two commits after fixing the same class twice.

In both lists now.

* Count the reopen paint floor instead of declaring it zero

growth() subtracts one ~33ms double-rAF vsync floor per wait a metric is clocked across, and the
count was a hand-declared integer in GROWTH_AXES. reopen ms declared 0. Reopening is driven by a
React state update, so the count check immediately after openThread() always still sees the
unmounted tree and the loop always pays at least one __nextPaint() before it can observe the
rebuilt messages. That is the same floor already subtracted from jump painted ms and delete ms.

Leaving it at 0 kept a full floor of constant baseline in both ends of the ratio, which drags the
ratio towards 1 and can report a real reopen curve as flat, worst when the smallest fixture
rebuilds near the vsync floor.

Rather than change the constant and leave the next one to be declared by hand, REOPEN_JS now
counts the waits it actually pays and returns them, and floor_declaration_problems compares the
declaration against the count for every engine and size, from harness_failures, so a mismatch
fails the run instead of being published. FLOOR_COUNTERS is keyed on the exact axis name, since a
prefix would let a later reopen settle ms axis be checked against this one's declaration. A cell
that reports no count is a failure rather than a skip; leaving it silent is how the check would
quietly stop checking.

The clean-cell fixture in the integrity tests gained the field, and it caught the change: with
the axis back at 0 the existing clean-cell test goes red too.

Eight assertions added, each made to fail on its own targeted broken tree before being kept: the
axis back at 0, the checker short-circuited, a missing count skipped rather than reported, the
crashed-cell guard removed, the checker unwired from harness_failures, the counter removed from
the loop, and mismatches accepted.

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

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

* Do not report an action that did not run as an unverified paint floor

Self-review of the floor check. An action that never ran carries no paintWaits, so the new check
reported its floor as unverified on top of the failure harness_failures already raises for the
action itself, with the reason. Two failures for one cause, and the second buries the first.

Skipped only when the action explicitly reports ran false. An action that DID run and still has
no count is still a failure, since that is the case where the subtraction is genuinely
unverified, and there is a test on that side too so the skip cannot be widened into a blanket
exemption. Both proven red: removing the skip, and widening it to always.

* Measure the wall floor per window, and stop filing exceptions as chatter

Two things.

The generated wall ms axes declared zero double-rAF waits for every action, while the explicit
axes declared theirs by hand. MENU_JS opens the recorder before opening the menu and closes it
after closing it, so it crosses the same two waits menu open+close ms correctly declares, and
menu wall ms was subtracting none of them. That leaves roughly two vsync floors of constant
baseline in both ends of the ratio, which compresses it towards 1 and can label a primary metric
flat on a small or fast fixture.

Rather than hand-declare a second set of numbers, the recorder now counts the waits each window
is clocked across and reports them, and the wall axes read that count from the row. growth()
accepts a callable floor for this. Waits taken outside a recorder window, ACTION_SETUPS among
them, are excluded by construction rather than by remembering to exclude them, and begin() zeroes
the counter so a window cannot inherit the waits of the ones before it.

Separately, console.error and uncaught pageerror went into the same list as Firefox's two
scroll-anchoring notices and were tolerated by the same > 4 allowance. Engine chatter is the
engine describing itself; an application exception is not that, and a single one inside a measured
interaction means the interaction did not do what the row says. Severity is preserved now, the
allowance applies to warnings only, and any error in seeding or in the measured actions fails the
run. The counts and the first message are printed either way.

Ten assertions added and each proven red on its own broken tree. Two did not go red on the first
attempt: the severity assertion pinned an exact one-line expression and stayed green when the
predicate was moved to its own line, and the counter-reset break did not apply at all because its
needle was mis-indented. Both are fixed and both now fail as they should.

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

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

* Require a meaningful rise before a zero-based counter counts as an answer

No ratio can be formed against zero, so DISCRIMINATION_RATIO never applied to the counter axes at
all and large > small was the entire test. The CI workflow runs one repetition on Chromium, so
there is no median to smooth a stray dropped frame, and harness_failures accepts any ONE
discriminating axis: 0 missed frames at 25K and 1 at 100K was marked as discriminating and could
carry the whole liveness verdict while every latency axis was flat or broken.

ZERO_BASED_MIN_RISE is what such a counter has to reach. 5 because these axes are dropped frames
and long tasks: at twelve times the content a real curve produces them in quantity, while one or
two is what an unloaded machine produces on its own. Absolute rather than a ratio because there
is nothing to divide by, and the reason string now distinguishes a counter that rose too little
from one that never moved, so a reader can see which happened.

Five assertions, each proven red on its own broken tree: back to a bare large > small, the
threshold lowered to 1, and every zero-based counter rejected outright. The last of those covers
the control, since a check that rejects every counter would leave the harness unable to report a
live run at all.

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

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

* Say which axes are counts instead of inferring it from the paint floor

The zero branch keyed on floored, which only identifies a timing that had a paint floor
subtracted. An unfloored timing does not have one: longest stall ms and worst frame ms read zero
at the smallest size whenever the action resolves before the recorder produces a sample, and were
then judged as dropped-frame counters, so a noisy 5ms at the largest size read as a rise of 5 and
discriminated. harness_failures accepts any single discriminating axis, so that stray millisecond
could carry a run in which every valid latency curve was flat.

COUNTER_AXES states which axes are counts. Only frames over 33ms is one; everything else is
milliseconds. A timing that reads zero at the smallest size is now reported as having no rise to
measure rather than being given a counter's credit.

Three assertions, each proven red on its own broken tree: timings judged as counters again, no
axis classified as a counter at all, and a timing axis classified as a count. The middle one
matters because emptying the set would silently turn every counter into a timing and remove the
only zero-based axis the liveness verdict has.

One correction: the first version of the set assertion required a counter's name not to end in
ms, which is wrong, since the counter axis is called frames over 33ms and does. It names the set
exactly now, which is the point of classifying it explicitly.

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

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

* Put the floor COUNT in the report, not the thing that computes it

Making the wall floor a callable put the lambda itself into the growth report. main() attaches
that report to results and json.dumps it, so a complete run raised Object of type function is not
JSON serializable after every measurement had already been taken, including the new CI smoke.
That is my regression from the previous commit and it broke every full run.

resolve_floor returns an int, growth uses it, and the report stores the resolved count at each end
of the ratio rather than a boolean. A boolean would serialise and say nothing; the counts let a
reader check the subtraction instead of trusting it.

The reason no test caught this is that none of them serialised the report, so three assertions now
do, and one of them covers the callable-floor axis specifically since that is the case that broke.
Both proven red: the callable put back into the report, and a boolean marker in place of the
count.

* Keep fractional paint floors, and hold counters to the noise floor at any baseline

Two follow-ons from the same review.

resolve_floor cast to int. summarise takes a median across repetitions, so a run whose
repetitions paid 1 and 2 waits reports 1.5, and truncating that left half a vsync floor in the
wall axis. The documented two-repetition configurations are precisely the ones that produce
halves. The median is kept as a float, which serialises fine.

The noise floor only applied when a counter started at exactly zero. A dropped-frame count going
1 to 2 is a ratio of 2.0, cleared DISCRIMINATION_RATIO, and since harness_failures accepts any
single discriminating axis, one incidental frame could carry the CI smoke while every latency
axis was flat. A ratio on a counter is only meaningful once there are enough events for it to be
about the content rather than about one frame either way, so the floor now applies whatever the
baseline, and the reason string says which of the two rules rejected the axis.

The floor stays a count of events and is NOT applied to timings, which would silently reject real
latency curves that happen to sit at low absolute values. That has its own test.

Five assertions, each proven red on its own broken tree: the floor truncated again, the noise
floor skipped for nonzero baselines, the noise floor applied to timings as well, and every
counter treated as noise, which is what covers the control.

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

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

* Subtract the whole window's floors from the axes that span it

quiet() and quietUntilIdle() return the elapsed time since this.startedAt, not the time they
themselves took, and gestureMs is computed from startedAt as well. All three therefore span the
entire recorder window and contain every double-rAF wait in it, and all three declared zero. For
the scroll that is twenty vsync floors left in both ends of the ratio, which compresses it hard
enough to report a real size-dependent regression as flat.

scroll gesture ms, scroll settle ms and jump settle ms now take the measured paint_waits. Counted
at runtime rather than declared, because the twenty come from a loop: the literal nextPaint count
in the source is one, so any number written in here would have been wrong the same way the zero
was.

Deliberately NOT applied to everything. jump painted ms starts at a mark taken after begin() and
spans one wait while the jump's window holds two, and MENU_JS awaits no paint at all, so its
window count is zero while its two floors are real, coming from settle() reading the
pre-MutationObserver state on entry for open and again for close. Giving either the window count
would subtract a floor the number never contained, or drop one that it did. The rule is that an
axis measured from startedAt takes the measured count and an axis measured from a later mark keeps
a declared one.

Seven assertions, each proven red on its own broken tree: each of the three axes back to zero,
jump painted given the whole-window floor, and menu given a window count of zero. That last break
also turned two PRE-EXISTING menu tests red, which independently confirms the menu axis really
does carry both of its floors. There is an end-to-end case too: with the floors left in, a 16x
scroll curve reads as 1.86x.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-18 04:33:11 -07:00
Michael Han
3bf9bff5dd
Say why a Xet download sits at 0% until it finishes (#9159)
* Say why a Xet download sits at 0% until it finishes

Xet fetches a model as parallel chunks and commits them in batches, so
progress reads 0% for a while and then completes at once. That looks like a
hung download, and it is the most common complaint about Xet.

Toast the reason on the first three model downloads that start on Xet, and
point at the HTTP transport in Model Hub for anyone who wants steadier
progress. The count is stored, with an in-memory carry so a browser that
refuses the write does not repeat the toast on every download.

* Skip the Xet notice when the start attached to a live job

The start endpoints accept a second client for a job already running and
report that job's transport, which is indistinguishable from a fresh Xet
start. Both now say which it was, so an attaching tab neither shows the
notice nor spends one of the three.

* Declare attached on the download start schemas

The routes set response_model, so a key the schema does not name is dropped
before it reaches the browser. The flag shipped in the handler but never in
the response, leaving the notice gate reading undefined.

* Skip the Xet notice on a start that is already stopping

A cancel can land while the start POST is in flight: the start still comes
back accepted, so the notice promised a running download the user had just
stopped, and spent one of the three doing it.

* Report attachment only when the start actually attached

The reject branch also covers a cross-variant conflict and an in-progress
delete, which join nothing. Both flags now come from the adoptable verdict.

* Reserve the Xet notice slot under a cross-tab lock

Two tabs starting a download at the same moment both read the count before
either wrote it, so a fourth toast could appear. Web Locks make the read and
the write one step; browsers without them keep the old bounded race.
2026-08-18 02:31:53 -07:00
Michael Han
c675be1adc
Open the connection form when Settings > Connections has nothing to list (#9157)
* Open the connection form when Settings > Connections has nothing to list

An install with no connections opened that page onto a single "No connections
yet" row, which never says what a connection is or how to add one. Open the
"New connection" form instead when the first sync comes back empty.

The check reads the synced backend response rather than the local snapshot, so
a stale empty list cannot flash the form at someone who already has
connections, and it waits for a loaded provider registry so the form never
opens with an empty dropdown. It fires once per visit: the back arrow, saving,
and deleting the last connection all leave the user where they navigated to.

* Let a manual navigation cancel the connections auto-open

The Add connection row stays live while the first sync runs, so a user can
open the form and go back before it lands. Only the sync consumed the
one-shot, so it then pulled them back in. Every navigation the user drives
consumes it now.
2026-08-18 02:02:25 -07:00
Daniel Han
571a99b72b
Studio: stop the overlay rail going click-through while it is scrolling (#9150)
* Studio: stop the overlay rail going click-through while it is scrolling

`Chat UI Tests (banner)` has been red on main since #9132, on the WebKit leg of
"Update banner layout, other engines", at 921x534 on New chat:

    FAIL the card keeps its full width whatever the scrollbar does
         cardWidth=430 want=448 railGutter=0 scrolls=True
    FAIL the rail takes pointer input exactly when it scrolls
         scrolls=True pointerEvents=none

Both assertions are from #8367 and both are right. The second one describes a
state a user can actually be in, and the shard's own screenshot shows it: the
rail is capped, the llama.cpp card is sliced off at the fold, the Unsloth update
card is entirely above it, and the rail is `pointer-events: none`, so there is no
scrollbar and no drag that brings it back. The suite's reachability checks pass
because they call scrollIntoView, which a reader has no way to do.

What #9132 changed is not the product, it is the state the suite finds. The
banner scripts used to run on the machine and the Studio home the chat and extra
shards had already been through; they now get their own runner and a pristine
`~/.unsloth/studio`, since boot-studio-api-only.sh wipes only `auth`. The last
green artifact is dark-themed with "Chat C" and "Chat B" in Recents; the first
red one is default-themed with "No chats yet". Same commit range, different
starting state, so the rail is capped now where it was not before. #9132 is
correct; it exposed this rather than caused it, and the same failure reproduces
on unrelated PR branches.

The defect is that `overflowing` is a prediction. `floorRoom > maxHeight` asks
whether the cards WOULD fit if they collapsed to the floor that was measured for
them under a max-height of 0. When they stop short of that floor the box
overflows a cap the prediction says it fits inside, and the rail is left
click-through while it scrolls, which is the failing pair exactly.

So the reading is kept and the actual state is or-ed into it, taken off the node
in the same synchronous block that already lifts and restores the cap, with the
real cap back on and the layout just flushed. It is re-read on every pass and
never remembered, which is what the derived value was there to protect against:
the observers watch the rail and every descendant, so a placement that grows to
fit clears the flag on the same pass that applied it. Being an `||`, it can only
add the case the prediction misses and can never take pointer input away from a
rail that already had it.

Verified on this box, WebKit and Chromium, against a build actually served from
this tree (`unsloth studio --frontend`; the venv's installed copy is what gets
served otherwise, which is worth knowing before trusting a local Studio run):

  - Broken deliberately, `overflowing` forced false: the full Chromium suite is
    1433 checks, 1 failed, and the one is
    `320x480 at 20px: the rail takes pointer input exactly when it scrolls
     scrolls=True pointerEvents=none`.
  - Fixed: 1433 checks, 0 failed. Firefox and WebKit spot: 221, 0 failed each.
  - Unchanged where the prediction was already right. Sweeping WebKit down the
    height axis at 921 wide, the rail and the flag agree at every step both
    before and after: no scroll and `none` at 534 through 364, scroll and `auto`
    from 360 down, card 448 throughout.

The width half is not fixed here, because I could not reproduce it and will not
guess at it. 448 - 430 is 18px of scrollbar, but `offsetWidth - clientWidth`
reports 0 in the same breath, so on that runner the bar takes width out of the
content box without showing up in the gutter the assertion reads. This box never
reproduces it: WebKit here keeps the card at 448 whether the rail scrolls or not.
`scrollbar-gutter: stable` is the obvious candidate and is deliberately not taken
on a guess, since it reserves the gutter permanently and would shift the cards
left by the scrollbar width on Chromium, where the card is already correct.

Instead both failing assertions now print the measurements that would settle it:
the card's transform and border box, its computed and max width, innerWidth and
documentElement.clientWidth, the rail's offset, client and content widths, its
scroll and client heights, its cap, and the children's heights. Nothing is
relaxed; the same two checks fail on the same conditions, with enough attached to
say which of the two mechanisms it is. That output is what the next run of this
shard is for.

* Take the rail's overflow reading after the transition is restored

The DOM reading was inserted between the flush of the restored cap and the
line that puts the eased transition back, which is the one place the store
must keep adjacent: update-banner-flex-priority asserts on it, and the
assertion went red on both the Linux and the Windows frontend unit jobs.

The reading does not need to sit there. The cap change is already committed
under the suppression by the flush above it, and transition is not itself a
transitionable property, so no reflow the read forces can hand that cap to an
animation. Moving it below the restore keeps the invariant and the reading.

* Measure the banner card's layout width, not its painted width

The width half of the banner failure is the card's enter animation, not a
scrollbar. The diagnostics added earlier in this branch settled it on the
WebKit leg: transform matrix(0.96, 0, 0, 0.96, 0, 12), which is exactly the
initial state in components/web/update-banner.tsx, with borderBox 448,
cssWidth 448px, railContentW 448 and railGutter 0. 448 * 0.96 is 430.08, and
430 is what the assertion read. Nothing had taken any width; the card had
simply not been painted at full size yet.

So the assertion was measuring the painted box for a question about layout.
getBoundingClientRect includes transforms and offsetWidth does not, so the
reading moves to offsetWidth, which a scrollbar taking width out of the
rail's content box still shrinks. The painted width is still reported next to
it, since the gap between the two is the diagnosis.

Checked on chromium, firefox and webkit that this is red exactly when it
should be: with the transform applied and no width lost, the old reading
fails and the new one passes; with 18px actually taken out of the rail's
content box the new one fails on all three, with or without the transform
also applied; on a settled healthy box both pass.
2026-08-18 01:41:05 -07:00
Michael Han
0af166062d
Drop the Exa preset from the MCP menu (#9160)
Leaves Unsloth Docs, Context7 and Hugging Face as the keyless presets. The
disablesWebSearch path stays, since it is a preset capability rather than an
Exa one, so the comment naming Exa is now generic.
2026-08-18 01:40:56 -07:00
Daniel Han
2073e0fe4d
Studio: load the settings tab panels when they are shown, not at launch (#8966)
* Studio: load the settings tab panels when they are shown, not at launch

* Studio: keep a settings panel that fails to load from taking the app down

A panel is fetched the first time it is shown, so it can now fail where it
could not before: offline, or a page whose entry bundle predates an in-place
rewrite of dist/ and still names chunks that have been replaced. The dialog is
mounted at the app root and nothing above it catches, so the throw unmounted
the whole of Studio rather than one panel. Blocking a panel's module in a
browser reproduced it: the dialog, its nav and the rest of the page went.

The panel area now sits in an error boundary that offers a reload, and the
Suspense fallback is a delayed loading line rather than an empty pane, so a
slow first open shows something and a prompt one still shows no flash.

Reload rather than retry: React caches a lazy rejection for the life of the
page and the browser's module map caches the failed import, so re-importing
the same URL rethrows without a new request. index.html is served no-store,
so a reload does pick up the current chunk names.

tests/settings-tab-panel-loading.test.ts gains a case that walks the JSX and
asserts every panel Suspense is inside a class that defines
getDerivedStateFromError. tests/studio/playwright_settings_tabs.py drives the
real dialog in a browser: all twelve tabs, deep-open, the search jump, and the
blocked-module case.

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

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

* Studio: consume a failed settings panel prefetch

The idle prefetch warms every panel once the dialog opens, so a chunk it
cannot fetch reached the page as an unhandled rejection for a tab nobody had
asked for. Reproduced by blocking one panel's module in a browser: the
rejection landed on window even though the boundary handled the panel that was
actually on screen.

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

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

* Studio: typecheck the settings smoke entry, and stop the harness settling on a placeholder

tsconfig.app.json lists the three existing smoke entries explicitly, so the new
one was outside the project and npm run typecheck skipped it. Added, and the
harness handle it installs on window is now optional, since app code sits in
the same project and must not be able to reach a handle only the harness page
installs. tsc --listFiles now names the file.

The Playwright harness settled on whatever held still for 600ms. The panel
renders from a deferred value, so a switch keeps the outgoing content up until
the incoming panel is ready, and on a loaded machine that hand-off outlives the
window: one run read the placeholder as the final panel and called a correct
recovery a failure. It now refuses to settle on something with almost no
content. A run that dies on a cold dev server also writes its report instead of
leaving none.

* Tighten the settings lazy-panel comments

* Name the encoding when the settings harness writes its report

tests/test_source_read_encoding.py holds every checked-in file read and write
in the test trees to an explicit utf-8, so it does not depend on the platform
default and break on Windows the day the file gains a non-ASCII byte. The
report write was the one that did not.

* Let the select's keyboard scroll settle before the font-scale wheel check

Pre-existing flake in this step, not something this branch introduced. The
step reads scrollTop straight after keyboard.press, but Radix scrolls the
highlighted item into view off the back of that keypress, so the value is a
mid-scroll sample: instrumented on the ubuntu CI image the viewport went on
to settle 24-35px further down in 20 runs out of 20, on this branch and on
its merge base alike.

Two things break as a result. The stale sample is not the floor the wheel
has to beat, which is why the failure reads '20 -> 44' as though the
viewport had moved the wrong way when 44 is simply where the keyboard
scroll ended up. And a wheel dispatched into a scroll Chromium is still
animating can be swallowed outright, which is the actual failure: at a
maximum scrollTop of 243 a working -400 wheel lands on 0 every time.

So wait for the scroll to stop before taking the floor, keep the pointer
inside a viewport that is not always 40px tall, and re-send the wheel on a
bounded retry. A viewport that genuinely refuses the wheel still never
moves and still fails, just after more tries.

* Clear an unconsumed archive deep-open when settings navigates away

* Run the settings tab-panel browser smoke in frontend CI

* Keep an archive deep-open when the navigation lands back on Data

* Keep a settings scroll target when its own tab is reselected

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

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

* Load the crypto polyfill on the settings smoke page

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

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

* Trim comments in the settings lazy-loading changes

* Hold only the Data panel's own module in the settings deep-open test

The abandoned-deep-open step routed every request through a handler that
sleeps 2.5s, and that sleep runs on the driver thread, so the whole page's
module load queued behind it and arrived at the main thread in one go. On a
two-core runner sharing the box with a live Studio that pushed the reopen
past its 15s timeout, which reads as a settings dialog that would not open
when nothing was wrong with it. Route the Data module alone.

The assertion is unchanged and still goes red on the pre-change store: the
next ordinary visit to Data reopens the archive listing.

* Name the cause when the settings smoke page has navigated away

Vite dev proxies /api to 127.0.0.1:8888. With a Studio listening there and
no token those calls answer 401, the app's auth handling navigates, and the
harness window goes with it, after which every step times out waiting for a
dialog that cannot exist. It happens on main too, where the harness is gone
before the first open, so it says nothing about the panels. Report it.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-08-18 00:10:58 -07:00
Daniel Han
e35b8a8447
Studio: stop a settings module reading a chat store key before it exists (#8979)
* Studio: stop a settings module reading a chat store key before it exists

* Fix two module resolution failures in the frontend test suite

* Fix the third Windows-only path failure in the frontend test suite

* Drop the #8980 content this branch no longer needs

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

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

* Trim the comments this PR added

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-18 00:10:42 -07:00
Daniel Han
c755c9e3ab
Studio: pin what the trailing-placeholder window does with nested placeholders (#9091)
* Pin what the trailing-placeholder window does with nested placeholders

The module comment says the window's guarantee is about what is removed
rather than about leaving an oversized fragment whole, and gives nested
placeholders as the case where those two readings come apart. Nothing pinned
it, so changing the behaviour to match the looser reading would have gone
unnoticed.

The test kills both directions: widening the window so the outer opener comes
back into range, and declining to strip whenever an opener predates the
window, which is the change a review of #9012 asked for.

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

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

* Studio: tighten the comments in the nested placeholder test

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-18 00:10:31 -07:00
Daniel Han
ec18cdd29f
Chat: cover which messages a delete takes with it (#9087)
* Chat: cover which messages a delete takes with it

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

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

* Pin which message the thread is left showing after a delete

The cases so far cover which messages a delete removes and how the survivors are
relinked, but not headId, and headId is not cosmetic: thread.import hands it straight
to MessageRepository.resetHead, which throws on an id it cannot find. A head left
naming the message that was just deleted therefore does not merely show the wrong
branch, it makes the import fail after the backend prune has already run, so the
server drops the message and the screen keeps it.

Three cases: the head always names a message that is still there (or null once the
thread is empty), deleting the reply on screen falls back to the sibling it was
regenerated from rather than to the prompt, and deleting a branch nobody is looking
at leaves the shown message shown.

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-18 00:10:06 -07:00
Daniel Han
17665d4a85
Studio: pin why a Streamdown remount keeps its highlighted code (#9048)
* Studio: pin why a Streamdown remount keeps its highlighted code

The incremental Markdown cache bumps `renderGeneration` when dropping
retained blocks cannot be signalled through the Markdown string, and that
generation is part of the <Streamdown> React key, so the block subtree
unmounts and remounts. That was read as losing Shiki's highlight state and
flashing every fence in the reply back to unstyled code. It does not, and
the reason is worth holding in place.

None of the highlighting state lives in the component tree. The wrapper in
code-plugin.ts is built once at module scope in markdown-text.tsx, so its
per-fence slots outlive any remount, and `@streamdown/code` keeps its
highlighter instances and its tokenized results in module-scope Maps and
answers a repeat request inline. A remount therefore re-asks for tokens it
already has and gets them back in the same tick.

Upstream reported and fixed exactly this. vercel/streamdown issue 186,
"Code block flickering in virtual lists due to async Shiki highlighting",
names "No caching between mounts: Highlighter state is lost when components
unmount", and was closed by PR 240, merged 2025-11-21, which rebuilt the
code blocks. The pinned `@streamdown/code` 1.1.1 carries that rebuild.
Nothing equivalent is open on shikijs/shiki, where the highlighter is an
object the consumer owns and caching it is the consumer's job.

Measured on this branch's base: with a 40 line TypeScript fence the cold
mount answers through its callback, and a fresh plugin standing in for the
remount answers synchronously in 0.010 ms with the same 40 token lines.

Two tests pin the halves of that. One drives a real highlight and then asks
a second plugin instance for the same fence, which has to answer inline.
The other walks markdown-text.tsx and requires the single
`createCodePlugin` call to sit at module scope rather than inside a
component. Both fail on a tree broken for them and only for them: returning
null instead of the synchronous cache hit from the wrapper's dispatch fails
the first and leaves the second passing, and moving the plugin construction
into a function fails the second and leaves the first passing.

No behaviour change. The key and the generation counter are left alone. On
this base, driving the cache through the renderer's own
stabilizeStreamingMarkdown(preprocessLaTeX(text), true) pipeline over
twelve streamed fixtures covering prose, fenced code, inline and display
LaTeX, currency dollars, and spans that stay open across thousands of
characters: at one character per arrival, 46,181 updates produced 26
non-prefix updates, 1 retained-block drop and 0 generation bumps; at four
characters per arrival, 11,550 updates produced 14 non-prefix updates, 1
drop and 0 bumps. The generation never moved off zero, so there is no
remount to remove and nothing for a rendering benchmark to show.

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

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

* Pin the remount test to this repo's plugin, not the dependency cache

The remount case built a fresh plugin wrapper, so the only thing keeping
its answer synchronous was the module-scope cache inside @streamdown/code.
Production hands Streamdown the same module-scope plugin across a remount,
so reuse the mounted one and the test pins our wrapper instead.

The threshold guard compared against a local copy of MIN_INCREMENTAL_CHARS,
so raising the production value to 100000 sent every fence down the small
fence shortcut with the test still green. Export the constant and import it.

Await the cold ask's callback rather than polling highlight(), so the warm
up loop no longer satisfies the remount assertion on its own.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-17 22:39:23 -07:00
Daniel Han
1f09a17868
Studio: keep the streaming render harness the perf PRs kept rebuilding (#8969)
* Studio: keep the streaming render harness the perf PRs kept rebuilding

* Studio: close the false-green paths in the stream pacing harness

The long-task total is the metric the budgets turn on, and it reads 0 both
when the render is free and when the observer never ran. observe({type:
"longtask"}) is specified to abort silently on an engine that lacks the
entry type rather than throw, so the try/catch around it never fired: under
firefox or webkit the harness scored a perfect zero and exited 0. Detect
support with PerformanceObserver.supportedEntryTypes, record it, and fail
the run when no long tasks were seen or when throttling was disabled.

Also:
- add the entry to tsconfig.app.json, which lists the smoke entries one by
  one, so npm run typecheck actually covers the 270 lines it was reported
  against
- write the JSON under logs/ like every sibling harness instead of dropping
  an untracked stream-pacing.json in the repo root, and create the directory
- treat an exported-but-empty SMOKE_BASE_URL as unset, matching the siblings;
  it drove "" as the base URL and burned the full readiness timeout
- register the harness in the two contract tests, which is what surfaced the
  SMOKE_BASE_URL bug, and pin the new guards there
- record the second mutation: reverting #7892 moves the longest stall 4-5x
  while leaving the long-task total inside the clean range, the exact
  opposite of reverting #8750, so both budgets are load-bearing

* Studio: budget only the long tasks the stream itself caused

buffered: true replays whatever the performance timeline already held, so
module evaluation and the first React render landed in the budgeted total:
one entry, ~140ms, about 2.6% of a clean run here, and larger on a cold or
loaded runner. Nothing filtered by startTime and run() reset nothing, so a
slow page load read as a slow renderer.

Open the measurement window in run() and drop entries that began before it.
Pre-stream share goes 2.6% to 0.00% while the stream's own tasks are
unchanged (60 and 52 entries over two clean runs), and reverting #8750 still
fails the budget at 52,465ms.

* Studio: tighten the stream pacing harness comments

Comments and docstrings only, no code change. Every measured number, PR
reference and causal reason is kept verbatim.

* Studio: check the reply that settled, not the peak it once reached

paintedChars is a high-water mark and only ever climbs, so a completion
render that truncated the bubble would leave the peak behind and the 90%
workload floor would still pass on a DOM that no longer held the reply.
Record what is on screen at settlement and check that too. Measured equal
to the peak today (24,033 both), so this is a guard rather than a live
discrepancy, and it is pinned in the harness contract test.

Also count slow frames only inside the measurement window and reset the
counter in run(), the same rule long tasks now follow. Contamination
measured at 0 of 286 here, but an external server or a slower box need not
be 0 and the number is meant to be comparable across them.

* Studio: record a stall that never ends, and keep the task that starts the stream

Two holes left by the measurement window added in the previous commits.

A long task carries the start time of its whole task, so appending the user
message in the same task that assigned measureFrom stamped runtime startup
and the first publish as earlier than the window and dropped them as page
load. Hand the append to a later task so the work that begins the stream
sorts inside it.

longestStallMs was only ever written when a later paint closed the stall, so
a freeze that ran to the end of the stream was never recorded: the tail can
go missing inside the 90% floor and the quiet-frame loop then calls it
settled. Measure the stall in progress while text is still arriving, which is
what the number means, and not afterwards, where the settle window's own
quiet frames would read as a freeze.

Clean runs unchanged (stall 933 to 1,050ms, long tasks 4,749 to 5,159ms over
three) and both mutations still caught: #7892 reverted fails the stall at
5,233ms, #8750 reverted fails long tasks at 52,263ms.

* Studio: tighten the comments added since the first pass

Comments only, no code change. Every measured number and every causal
reason is kept.

* Studio: record a freeze that spans the end of the stream

The stall in progress was measured only while text was still arriving. A
freeze that spans the moment the stream ends blocks the frame loop across
it, so the first frame afterwards already observes a non-null
streamEndedAtMs and the whole frozen interval was skipped. With the lost
tail able to hide inside the 90% workload floor, thirty quiet frames then
settled the reply and the run reported a short longest stall, which is the
one shape this number exists to catch.

Cap the interval at the absolute stream-end timestamp instead. A freeze
across that moment is recorded in full, and the stall stops growing once
there is no more text to wait for, so the settle check's own quiet frames
are still not counted as a freeze.

The rule moves into smoke-stream-pacing-stall.ts so it can be tested
without importing the harness entry, which mounts React on import. The new
tests cover the spanning freeze, the settle-window bound, idempotence and
late tail paint; restoring the previous rule fails two of the five.

Clean runs unchanged (stall 967 to 983ms, long tasks 5,442 to 5,842ms) and
both mutations still caught: #7892 reverted fails the stall at 5,017ms,
#8750 reverted fails long tasks at 63,687ms.

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

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

* Make the stream pacing smoke page load again

Merging main brought in ASSISTANT_PART_COMPONENTS, which thread.tsx builds
at module scope with Text: MarkdownText. Entering the markdown-text ->
features/chat -> chat-page -> thread cycle from markdown-text runs that
object literal while the MarkdownText binding is still in its temporal dead
zone, so the page died with Cannot access MarkdownText before initialization
and rendered nothing. Import the chat barrel first, as the app's entry does.

The page is also a new HTML entry, so it has to load the crypto polyfill
before its module entry like every other one.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-17 22:38:56 -07:00
Daniel Han
019ede777d
Stop the banner layout suite waiting out the update timer on every boot (#9105)
* Stop the banner layout suite waiting out the update timer on every boot

Update banner layout regression is the largest step in Chat UI Tests at 4.98 of
the job's 17.6 minutes, and the second banner step adds 2.27 more. Together that
is 41 percent of a job that runs on Linux, Windows and macOS.

Where it goes: the suite boots a fresh page for each case it measures, and the
card it is measuring is mounted by the app's 5s update-check timer, so
wait_for_selector on it cannot return sooner than that. At 33 boots the timer
alone is over two and a half minutes. The remaining per-boot time is the 900ms
animation settle and settle_stack, both of which are render waits, both of which
this leaves exactly as they are. Measured earlier as 33 gaps of about 7.5s each,
which is 5 for the timer plus 0.9 plus roughly 1.6 of stability polling: the
stability poll is converging, not exhausting, so it is not the thing to change.

So the delay becomes overridable from a global that the hook reads AT MOUNT, and
the suite sets it in the init script it already installs, to 150ms. Not zero: a
card that mounts on the first frame would stop exercising the late-mount reflow
this file exists to measure. Production is untouched, since the global is
undefined in every real browser and the 5000ms constant stands, and it is a
global rather than an env var so no build can bake the short delay in.

The two sides are connected by nothing but the spelling, and a typo on either
would fail in the worst way available: the timer quietly stays at 5s, the step
goes back to being slow, and every assertion still passes. The new test asserts
the spelling, that the suite sets it before the app runs rather than after
navigation, and that the production constant is still 5000.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-17 18:48:01 -07:00
Maheswar Kumar
ea687ef520
studio: add settings-managed LAN access (#8951)
* studio: add settings-managed LAN access

Studio binds 127.0.0.1, so reaching it from a phone on the same Wi-Fi meant
relaunching with -H 0.0.0.0, which the desktop app cannot do at all. Settings >
API keys > LAN access now adds a second uvicorn listener over the running app,
bound to each detected non-loopback IPv4 at the same port, on the primary
server's event loop with lifespan="off". The loopback socket is untouched, so
the desktop app keeps working while it is on.

lan_access.py owns the listener: address detection, binding, and a teardown that
waits on the sockets rather than on serve(), since uvicorn closes the passed
sockets at the top of shutdown and only then drains in-flight responses. Waiting
on the serve task made a Stop pressed from a LAN device wait out its own
response for the full timeout.

utils/lan_access_settings.py holds the launch policy and the persisted
lan_access_auto_start preference. Start is blocked on Colab, on a --secure
launch (which forces the loopback bind so the raw port is never exposed), on a
launch that already binds the network, and until the seeded admin password has
been changed.

main.py's desktop SPA gate now opens for requests arriving on a LAN listener
socket, identified by scope["server"] rather than any client header, so a
desktop api-only backend serves its packaged web UI over LAN without changing
the local api-only surface. host_policy tracks the LAN listener alongside the
Cloudflare tunnel, so turning it on suspends the loopback stdio-MCP default.
The startup banner reports the bound addresses when a persisted auto-start has
already brought the listener up, instead of claiming loopback only.

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

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

* studio: keep LAN listener ownership until the port is confirmed closed

A stop that timed out dropped every reference to the server and sockets before
returning False, so the wait could never be retried and a second stop found
_server None, reported success, and cleared both the stop_timed_out error and
the beyond-loopback trust flag while the port could still be accepting. The
references are now retained on that path, so the status keeps offering Stop and
a retry waits on the same sockets.

start_lan_access also marks the LAN connector active before the listener can
accept, rolling back if startup fails. A request served between the socket
accepting and the flag being set would otherwise read the loopback-only stdio
MCP default in core/inference/mcp_client.py.

* studio: keep the LAN trust flag under the listener lock

start_lan_access and stop_lan_access are sync routes, so FastAPI runs them in
separate worker threads. Publishing the beyond-loopback flag from those callers
left it outside lan_access._lock: a stop that observed no server could set the
flag false between a concurrent start setting it true and that start acquiring
the lock, leaving a live LAN listener while remote_connector_active() read
false and core/inference/mcp_client.py still allowed the loopback-default stdio
MCP transport.

The flag now changes only where the listener state does, under the same lock:
start_lan_listener sets it before the socket can accept and rolls it back if
startup fails, and _release_listener_state clears it with the references.

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

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

* studio: enumerate interfaces for LAN access and hold ownership through loop shutdown

detect_lan_addresses only had the route to 8.8.8.8 and a hostname lookup. The
probe yields one route-selected source address and hostname resolution is not an
enumeration, so a multihomed host lost its second adapter and an isolated LAN
with no default route reported no_lan_address while having a usable address, the
Linux name-to-127.0.1.1 mapping making it worse. It now enumerates IPv4
addresses on every interface that is up via psutil, keeping the default-route
address first so it stays the shown URL, and falls back to the hostname lookup
only when psutil is unavailable.

stop_lan_listener no longer releases ownership on the event-loop branch.
/api/shutdown reaches it from a task on the serving loop, and uvicorn cannot
close the sockets until that loop is free, which _graceful_shutdown then holds
for seconds while it stops the inference, export and training subprocesses. Only
_bound_addresses is dropped there, so the frontend gate closes at once while the
sockets and the beyond-loopback trust flag stay owned until they really close.

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

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

* studio: hold LAN trust until the stopped listener's requests drain

Closing the listening sockets stops new connections, but uvicorn then drains the
ones it already accepted. Clearing the beyond-loopback flag at that point let a
LAN request still executing reach stdio_mcp_enabled() in
core/inference/mcp_client.py and be treated as loopback-only, so a remote caller
could spawn stdio MCP subprocesses under the local default. Stopping LAN access
while a long inference from a phone is in flight is an ordinary way to hit it.

The confirmed-close path now releases the listener references without the flag
and hands it to a drain watcher, which clears it once server_state.connections
empties. The watcher leaves the flag active if the connections never drain, and
skips the clear when a new listener has since taken ownership. The Stop response
itself still returns as soon as the sockets close.

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

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

* studio: derive the LAN trust flag from listener and drain ownership

A second stop arriving after the first closed the sockets but while accepted
requests were still draining found _server already None and released the flag
outright, so those still-remote requests passed the loopback-default stdio MCP
gate. The idempotent stop path bypassed the drain watcher entirely.

The flag is no longer assigned by any path. _sync_lan_trust publishes it from
the authoritative state, a live listener or any stopped listener still draining,
counted by _pending_drains and taken under the same lock as the references. A
watcher whose connections never drain keeps its count rather than releasing, so
the flag fails closed. start_lan_listener still raises it explicitly before the
socket can accept.

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

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

* studio: release bound sockets when the listener cannot be scheduled

asyncio.run_coroutine_threadsafe raises RuntimeError("Event loop is closed") if
the primary loop closes between _server_loop validating it and the schedule
call. That escaped past the failure cleanup with every socket already bound and
the trust flag already raised, so the port stayed listening with nothing serving
it, status reported no listener, later starts hit bind_failed, and the flag
stayed on until the process exited.

The cleanup is now shared by both start failure paths in _fail_start, which
closes the sockets, resyncs the trust flag from ownership and records
listener_start_failed. The unscheduled coroutine is closed so the failure does
not also emit a never-awaited warning.

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

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

* studio: unblock a loop-side LAN stop and release the listener when its loop ends

start_lan_listener holds _lock while waiting for the serving loop to run
serve(). /api/shutdown reaches stop_lan_listener from a task on that same loop,
so blocking on the lock left the two waiting each other out: the loop could not
advance the scheduled listener, the start burned its full timeout and shutdown
stalled with it. A stop running on the serving loop now takes the lock without
blocking and reports an unconfirmed stop instead.

The event-loop branch keeps ownership so uvicorn can close the sockets once the
loop is free, but nothing released it afterwards. An embedded host that calls
run_server again in-process therefore saw a stale _server, reported the previous
addresses as online and never bound a new listener. run.py's server thread now
releases the listener in the same finally that closes the loop, which reaches
the loop-gone path and drops the sockets, references and trust flag.

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

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

* studio: advertise only reachable LAN origins

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
2026-08-18 04:32:59 +03:00
Long Yixing
31c42e872b
Restore crypto.randomUUID before the Studio bundle evaluates (#9075) 2026-08-17 19:57:20 -03:00
Nilay
ea958c7f34
Studio: let the hub run non-GGUF image and video models (#8855) 2026-08-17 18:04:50 -03:00
Vineeth Sai Varikuntla
a4fa451214
Derive warmup steps from a model default that gives a ratio (#8723)
Some checks are pending
Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Unsloth GGUF CI / JSON, images (push) Waiting to run
Unsloth load-orchestrator CI / test (push) Waiting to run
Mac Studio GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio UI + API + Update CI / Chat UI, API and Update Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (macos) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (push) Waiting to run
Unsloth UI CI / Loaded-models indicator (cross-browser) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Application Control CI / installer survives a denied unsloth.exe (push) Waiting to run
Workflow trigger lint / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
---------

Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-08-17 15:40:55 -03:00