Commit graph

258 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
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
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
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
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
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
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
Vineeth Sai Varikuntla
4b18f07226
Pair the diffusion warmup presets with a scheduler that uses them (#8593)
---------

Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-08-17 14:30:31 -03:00
Daniel Han
5a1655ca1b
Studio: run the frontend unit tests on Windows in CI (#9099)
* Run the frontend unit tests on Windows in CI, and fix the one test that cannot pass there

Frontend CI is ubuntu-only, so a test that is correct on POSIX and wrong on
Windows stays green forever. Thirteen such failures accumulated across three
files before anyone ran the suite on a Windows runner.

Add a windows-latest job that does checkout, node, npm ci and npm test, and
nothing else: typecheck, build and the browser smokes cannot fail there for a
reason they would not also fail on ubuntu, and repeating them would roughly
triple the workflow.

background-load-notice's stall-window test cannot pass on a Windows timer, and
was measuring nothing on Linux either. Replace the millisecond budget with a
comparison against the same loop with no healthy read.

* Audit the lockfile before the Windows install, and run no install script

Review: jobs run concurrently, so the ubuntu job rejecting a lockfile does not
help a Windows runner that has already installed it, and npm 10 ignores the
npm-11-only allowScripts policy. Run the same pre-install structural scan here,
and install with --ignore-scripts so no package script runs on this runner at
all.
2026-08-17 07:29:31 -07:00
Daniel Han
677d93ae31
Studio: fix the Windows-only path failures in the frontend test suite, and catch the shape from Linux (#8980)
* Fix two module resolution failures in the frontend test suite

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

* Fix the third Windows-only path failure, and catch the shape from Linux

microphone-permission-reset.test.ts builds its dynamic import specifier with
fileURLToPath, the same mistake copy-to-clipboard.test.ts made. On Windows that
is a "D:\\..." path, which node's ESM loader rejects, and the three tests that
load the module fail.

The suite now also asserts both shapes are absent, from any platform, so
Frontend CI on ubuntu can see a Windows-only path defect being reintroduced.

* Carry the portability taint on bindings, and scan every path argument

Review: a file-wide set of names let 'const path = url.pathname' in one scope
reject an unrelated 'const path = new URL(...)' read in another, and scanning
only arguments[0] missed copyFile(source, destination.pathname).

Resolve identifiers through their enclosing scopes instead, and map each fs
entry point to how many of its leading arguments are a path. Both are covered
by the file's own detector cases: reverting either fails them.

* Close three more gaps in the portability rules

Review: a value built in steps (let path = ""; path = url.pathname) was
invisible, an fs entry point imported under an alias was not recognised as one,
and the entry point list stopped at the read/write core.

Track assignment as well as initialization, resolve a callee through its import
binding so the module's own name is what is matched, and extend the list to the
stream, link, glob and metadata entry points. Each has its own detector case;
reverting any one of the three fails them.

* Match the portability rules on the module, not on the spelling

Review: an aliased fileURLToPath did not seed the native-path taint, and an
unrelated method sharing a name with an fs entry point (router.open, dom.link)
was classified as one.

A call is now resolved to its import binding and to the module that binding came
from, so node:url and node:fs decide both rules and a local spelling decides
neither. Namespace imports are resolved too. Three more detector cases cover it:
aliased fileURLToPath, fs.readFileSync through a namespace import, and a
router.open on a URL pathname that must stay quiet.

* Carry the taint across helpers and destructuring, and stop it at pathToFileURL

Review, three items on the portability rules.

A pathname crossing a local helper boundary was invisible: the argument is
tainted, the parameter it lands on was not. Taint now flows from a call argument
to the parameter of a locally declared function or arrow, which also covers the
same shape around dynamic import().

Destructuring introduced bindings the declaration table discarded, so
'const { pathname } = url' followed by readFile(pathname) passed. Binding
elements are now recorded individually, and an object binding element naming
pathname seeds the taint the way a member access does, so only that element is
tainted and its siblings stay clean.

The opposite risk, a false positive that would get the rule weakened: taint now
stops at pathToFileURL, which is the exact inverse of fileURLToPath, so the
standard round trip for a module location that starts life as a path is accepted.
Deliberately not a barrier for the pathname rule, where a Windows pathname is
'/D:/...' and converting it produces a URL for a file that does not exist.

Each has its own case, and the sanitizer has an inverse one asserting the
unrepaired forms still fire.
2026-08-17 07:28:39 -07:00
Daniel Han
2d8e0a00f7
Studio: strip the trailing template placeholder once, on the finished reply (#9101)
Fixes #9098.

The trailing `${...}` strip ran on every SSE arrival and assigned its
result back, so "ends with ${...}" was tested against every prefix of the
reply rather than the reply. The one arrival whose buffer ended at a
complete fragment was cut, and the reassignment made the cut permanent:

    in     return `Hi, ${name}!`      21 chars
    out    return `Hi,!`             13 chars

The strip now runs once, after the stream has finished. The fragment it
was added for in #4706 is still removed, because that one really is at
the end of a completed answer.

Rebased onto #9049. The watch it added still gates the scan, and now
saves the whole reply from being flattened rather than one arrival's
worth, so nothing on the arrival path can flatten the buffer at all.

Abort keeps the buffer whole: that tail is a prefix again, so stripping
it would be the same bug. `producedReplyText` is the same case one step
in, for a continuation that finishes without a text or reasoning delta
and so holds nothing but the partial it was seeded with.
2026-08-17 07:25:33 -07:00
Daniel Han
4f0d691cfc
Studio: stop the streamed reply being flattened on every arrival (#9049)
* Studio: stop the streamed reply being flattened on every arrival

PR 9012 removed two whole-buffer scans from the chat stream adapter and
left about 16 ms per side behind. That remainder is not a scan. Every
`cumulativeText += delta` leaves a cons string, and the first thing that
reads it copies the whole reply flat, so a single `charCodeAt` costs the
same as a full scan. Measured over a 220,000 character reply of 55,005
arrivals, medians of 5 to 7 repetitions, paired and interleaved:

  append only, nothing reads the buffer          0.84 ms
  one bounded scan of the tail per arrival    1715.98 ms

Three steps ran per arrival and all three read the buffer, so all three
paid that. Each now takes what the arrival added instead.

The think-tag tracker takes the delta rather than slicing it back out of
the buffer, and keeps the seven characters in front of it itself, which
is the most a tag split across arrivals can hide behind. 1743.92 ms to
4.19 ms at 220K.

The trailing `${...}` strip cannot avoid touching the end of the reply
when it fires, so a watch decides whether it could fire at all, from the
deltas: the last non-whitespace character, the last `${`, and the two
most recent `}`. It never says no when the strip would cut, so nothing
that used to be stripped survives, and a reply that never ends in a
brace never wakes the strip. 1715.98 ms to 6.74 ms at 220K.

`parseAssistantContent` over the whole reply, which `liveAssistantContent`
runs on every arrival, becomes an incremental parse that keeps the parts
it has already produced and extends them with the delta. It holds back
the trailing characters that could still turn out to be a tag, so `<thi`
is text until `nk>` arrives and reasoning after, matching a full reparse
at every state. Runs are cut at the tool-call cursors exactly as before,
including that think state resets at a boundary. 1978.78 ms to 7.37 ms
at 220K.

The retained state describes an append-only reply. A rewritten prefix, a
removed suffix or a tool call landing behind the end shows up as a length
or boundary mismatch and reparses from the buffer, which is what this
replaces, so those paths are no slower than before. An external
continuation whose prefix `joinContinuation` may repair never uses the
incremental path at all.

Whole per-arrival path, before to after, medians of 5:

   55,000 chars   13,755 arrivals    236.89 ms ->  4.06 ms    58x
  110,000 chars   27,505 arrivals    795.09 ms ->  5.73 ms   139x
  220,000 chars   55,005 arrivals   4882.98 ms -> 13.06 ms   374x
  400,000 chars  100,005 arrivals  17060.48 ms -> 21.57 ms   791x

The factor grows with the reply because the old cost was quadratic and
steepens again past 131,072 characters, where a flattened string stops
fitting in a regular heap object.

Tests. A differential fuzz test drives random arrival streams, with tags
split at every offset, tool boundaries, unclosed tags, empty arrivals and
truncations, and requires the incremental parse to deep-equal a full
reparse after every single arrival. The think tracker and the placeholder
watch keep their existing suites, driven through the new delta API, and
the watch gains a soundness sweep: for every state of every stream, if
the strip would cut, the watch admitted it.

Two source pins from 9012 are repointed rather than dropped. The one that
asserted the exact spelling of the tracker call now asserts what that
spelling protected: the reply grows through one call, which is what keeps
all three delta-fed pieces in step. A new pin lists the forms in which
the loop may mention the buffer at all, because character counting cannot
see this defect: a `charCodeAt` and a full scan read the same characters
and cost the same, so only a rule about touching the buffer catches it.

* Put the placeholder-watch gate under a buffer longer than its reseed window

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-17 06:38:28 -07:00
Daniel Han
bf60ff8b23
Studio: stop the streaming prefix checks scanning the whole reply (#9038)
* Studio: stop the streaming prefix checks scanning the whole reply

Four checks on the streaming path ask whether the new text still begins
with what was already handled. All four used startsWith, which scans.
Slicing to the prefix length and comparing lets V8 reject on length and
then compare natively.

Measured over a 60,000 character stream, 1,052 comparisons, median of 5
interleaved runs:

  strings sharing a parent   79.8 ms -> 1.1 ms
  strings not sharing one    74.3 ms -> 1.6 ms

Both cases are given because a shared-parent microbenchmark can
exaggerate this; here it does not, the effect survives flattening.

Call sites: three in streaming-render-schedule.ts, one of them added by
the retained-prefix work, and the coalescer in markdown-text.tsx whose
own comment already put its scan at 59 ms across a 175,000 character
stream.

The substitution is exact. slice clamps, so a prefix longer than the
string yields a short slice that cannot equal it.

markdown-streaming-scheduling.test.ts asserted the literal spelling
"text.startsWith(displayed.text)", so it failed on a change that keeps
the behaviour identical. It now asserts both halves of the gate and
accepts either spelling: the length rejection has to be there, since the
compare alone would run on every arrival, and the prefix compare has to
be there, since the length check alone would pass a comparison against
the wrong string.

Testing: an equivalence fuzz, 20,000 randomised cases plus a fixed
corpus and every cut of astral text, asserting agreement with startsWith
throughout. It requires at least 4,000 of the cases to be real prefixes,
so it cannot pass by rejecting everything on length.

Mutation results, stated per mutation:
  off-by-one on the slice        caught, 3 of 3 fail
  comparison in the wrong order  caught, 3 of 3 fail
  length guard removed           NOT caught, and cannot be: slice clamps,
                                 so that mutation is semantically
                                 identical and only loses the fast path

npm test 2,946 passed, 0 failed. typecheck clean. biome adds no errors.

* Cover case in the prefix corpus and correct what the measurement showed

* Make the streaming prefix tests fail when the change is reverted

The scheduling test accepted either spelling of the coalescer gate, and the
previous code satisfies the startsWith half, so it passed unchanged on a tree
with this PR fully backed out. Pin the slice form and reject the scanning one;
verified by reverting markdown-text.tsx to the old spelling, which now fails
where it passed 5 of 5 before.

Nothing covered the three call sites in the cache at all. The two spellings
return the same answer, so no output test can separate them and reverting the
call sites was free. Assert instead that no one-argument startsWith survives on
that path; the one call the helper cannot express takes a start position and
compares a fixed block, so it does not grow with the reply and is excluded by
the same rule rather than by an exception. Verified by reverting two call sites,
which now fails.

Drop the duplicated measurement at the coalescer and point at the helper.

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-17 06:38:19 -07:00
Daniel Han
31d1524b09
Studio: sweep the streaming markdown corpus at every prefix (#9085)
* Sweep the streaming markdown corpus at every prefix

The retained block list has to equal a whole-document Streamdown split at every
prefix of a reply. That property is what the incremental cache rests on, and
hand-picked fixtures do not find its counterexamples: the one that reached main
needed a display span whose body spans blank lines, so the newline the rewrite
inserts merges with the blank line in front of the opener.

Sweep a corpus instead. One named case per construct, plus 120 generated replies
in a loose and a tight form, all at every prefix, with a fixed seed so a failure
reproduces from the message. Adds a cost invariant too: rebuilds of the retained
prefix must not grow with the reply, since each one costs the whole reply so far.

Runs in 1.8s. Checked against three deliberately broken trees: dropping the
rollback window between a rewound boundary and the rewrite fails both sweeps,
and disabling the rewind or retention entirely fails the growth invariant.

* Make the corpus sweep reach the retained prefix, and fail on a rename

Two ways the first version measured less than it claimed.

A construct on its own is shorter than the rollback window, so candidateCount is
zero and nothing is ever committed: 37 of the 39 named cases never retained a
single block, and for those the sweep only asserted that repairTail agrees with
remend on a fresh context. Sweep each case a second time behind a lead-in, and
assert that the retention actually happened, so this cannot regress quietly.

The growth assertion read the same private counter on both sides, so renaming
the field made it undefined === undefined and the file passed while measuring
nothing. Verified: renaming retainedPrefixRebuilds left all four green.

Detection after, per injected defect: rollback window removed 2 fail, rewind
disabled 1, never commits 2 (was 1), counter renamed 1 (was 0). The generated
count drops from 120 to 40, which catches the same defects, and the file runs in
1.0s rather than 1.8s despite covering more.
2026-08-17 05:06:11 -07:00