Skip to content

[pull] main from danny-avila:main - #261

Merged
pull[bot] merged 22 commits into
innFactory:mainfrom
danny-avila:main
Sep 14, 2026
Merged

pull[bot] merged 22 commits into
innFactory:mainfrom
danny-avila:main

Conversation

@pull

@pull pull Bot commented Sep 14, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

danny-avila and others added 22 commits September 13, 2026 02:48
* 🧭 fix: Resolve People Picker Search Types Once

The people picker access check and the principal search handler each parsed
the requested principal types on their own: the check honored both `type`
and `types`, while the search read only `types` and treated an absent or
unrecognized filter as every type. Searches now use the types the access
check resolved, so a request without a usable filter covers only the types
the role can view, and the Entra ID lookup follows the same set.

Moves the access check into packages/api as createPeoplePickerAccess.

* 🧭 refactor: Move Principal Search Handler into packages/api

Lifts the search-principals handler into createPrincipalSearch with its
database and Microsoft Graph calls injected, leaving the controller as
wiring. The people picker access check now takes a plain permissions
shape instead of the stored role document.

* 🧭 refactor: Type Principal Search Requests Without Stored User Documents

The exported request type now names the query, headers and the few user
fields the handlers read, instead of extending ServerRequest and its IUser.
* 🧬 fix: Resolve Large Conversation Import Lineage in Linear Time

Parent lookups, timestamp ordering, skipped-ancestor resolution and
citation linking during conversation import each rescanned the batch or
the text per message, so a large export spent minutes on the request.
They now run in a single pass, lifted into packages/api as
createChatGptLineage, linkChatGptCitations and orderMessageLineage.
Fork cloning and branch collection use id maps for the same lookups.

* 🧬 refactor: Lift Fork Clone Lineage Into packages/api

cloneLineage assigns clone ids, re-links parents and orders timestamps,
and getAllMessagesUpToParent moves over whole with its id map, so
fork.js only supplies the id generator and persists the clones.
)

* 🔘 style: Seat Sidebar Row Actions One Radius Inside Their Row

Every control that sits on a sidebar row took the base `rounded-lg`, the same
radius as the row hosting it, so a hovered button filled corner to corner with
its row. `row-action` and `section-action` now carry `rounded-md`, one step
inside the rows, which stay as they were.

Three controls also diverged from the recipe they were built on. `ConvoOptions`
and the Projects heading action overrode the variant's hover fill with
`surface-hover`; the rename form's save and cancel buttons answered the pointer
with `hover:opacity-70` and no fill at all. All four now take the shared hover.

The project row's two actions are the exception that earns its own recipe: they
sit on a row that fills on hover, where the variant's hover surface reads as a
second, weaker hover stacked on the first. They take `surface-active` instead,
under the pointer and while the menu one of them owns is open, and sit 4px apart
like a pinned chat's own pair.

* 🧲 fix: Give Both Pinned Row Kinds One Unpin Badge, and Stop Its Flicker

Pinned chats and pinned agents/models sit in one list and are dragged against
each other, but their unpin badges were two controls: the chat's was
`text-text-primary` and always visible, the favorite's was a hand-rolled copy of
the shared row action, `text-text-secondary`, revealed on hover. `UnpinButton`
is now the single badge both rows render. It keeps the favorites' reveal, holds
open while the row's overflow menu is, and is inert while hidden so it cannot
swallow a click meant for the row.

The reveal is asked for in JS rather than through an `@media (hover: hover)`
variant: the variant has to beat the `group-hover` rules that reveal the badge,
and it loses that cascade for `opacity` while winning it for `pointer-events`,
which left the badge visible and unclickable.

Two things made a row's controls flicker under the pointer:

- The overflow slot took its width from its content, and the menu mounts a tick
  after the row is hovered. The unpin badge beside it slid 28px left at that
  moment, out from under the pointer, mid-fill; whatever landed there started
  its own. The slot now reserves its width from the row's hover, and `shrink-0`
  keeps a growing sibling from squeezing it.
- `hasInteracted` unmounted that menu on every leave, so each entry mounted a
  fresh button one frame late into an already-revealed slot, restarting its fill
  from transparent. A row that has been reached now keeps its control for as
  long as the row lives; virtualization still drops the whole row.

* 🧺 feat: Unpin a Chat by Dropping It on Chats, and Keep Pinned Kinds Apart

Dropping a chat on the Chats section filed it out of its project but left a
pinned one pinned, so the section it landed in was not the section it went to.
The drop now also unpins, and accepts a pinned chat that is already in the root
list; a chat that is both filed and pinned comes out of both, each call a no-op
for the half that already holds.

Pinned chats and pinned agents/models could be dragged through each other, which
the list cannot honour: the two are ordered independently and a row dropped into
the other group snapped back. Each row now accepts only its own drag type, so
the other kind never previews a displacement, and Alt+Arrow refuses the same
step. An order saved while the two interleaved is read back grouped, or a row
would be walled in by neighbours it is not allowed to swap with.

* 🧹 fix: Unnest the Row Action Width Ternary and Correct the Pinned Doc

The rebase onto `dev` left two things the branch's own checks catch: the width
branch that reserves a chat row's action slot had grown a nested ternary, which
the repository's ESLint forbids, and `dnd.ts` carried an import out of order.

The section's own doc comment still promised that favorites and conversations
"interleave freely", which this branch stopped being true: a row only reorders
against its own kind, and a stored order is read back grouped.

* 🪃 fix: Keep a Pinned Reorder That Was Released on a Row

Reordering the Pinned section with the pointer never survived the release. The
rows shifted under the drag, and the moment the button came up they snapped back
to where they had started; nothing was written, so a reload showed the old order
too. Only Alt+Arrow could actually reorder the list.

A row is a drop target — that is what makes the hover that reorders the list
arrive at all, since the HTML5 backend reports hover only for targets that could
receive the drag. react-dnd then counts that target as having handled the drop
whether or not it carries a `drop` handler, so `monitor.didDrop()` was true for
every release on a row, and the drag's `end` reads that as "this chat was filed
somewhere else" and discards the arrangement the drag built.

The row now answers the release with a result that names it, and `end` treats a
handled drop as a filing action only when the result is not that one. The same
change stops a row swallowing the drop of an unpinned chat that lands on it:
that drop reaches the section underneath, which is what pins the chat.

Covered by `dropping-a-chat-on-a-pinned-row-pins-it` and the same-kind control
inside `a-pinned-chat-cannot-be-dragged-through-a-pinned-model`, both in
`e2e/specs/mock/scenarios/pinned-drag-rules.spec.ts`.

* 🧪 test: Drive the Sidebar Row Rules in a Real Browser

Twelve scenarios in the mock harness, one per behaviour this branch claims: the
radius and fill every row control draws inside its row, the one unpin badge both
pinned row kinds carry and when it shows, the controls holding still while the
pointer crosses a row, the touch tap that reaches the badge directly, the drop on
Chats that unpins, the drop on a pinned row that pins, the kind boundary a drag
and Alt+Arrow both refuse, and the stored interleaved order that loads grouped.

They seed pinned chats straight into Mongo and set favourites and the pinned
order through the routes the star and the drag use, so the server's own cache
invalidation runs. Pointer-only scenarios skip on a pointer that cannot hover and
the touch scenario skips where it can, which is what lets the same file cover the
desktop and mobile projects.

`dragRowOnto` steps the pointer rather than jumping: the list reorders on
`dragover` once the pointer has crossed the hovered row's midpoint, and a single
move never delivers enough of them.

* 🧭 fix: Sequence the Two Halves of a Drop on Chats, and Reach the Mobile Drawer

Dropping a chat that is both pinned and filed in a project asked for the two
writes at once. The pin route answers with the conversation as it stands after
its own write, and the sidebar publishes that answer into every list, so a pin
that overlapped the project write could put the old `chatProjectId` back over
the lists the assignment had just corrected — and a failed assignment still left
the chat unpinned, out of the section it had been dragged from and still in the
project it was supposed to leave.

The unpin now waits for the assignment and runs only if it took, so a failure
leaves the chat exactly where it was and the published snapshot is the one taken
after both writes. `useAssignDroppedConversation` reports that outcome through a
named `AssignDroppedConversation` type, and the project row's drop keeps handing
back nothing, since the pinned list reads the drop result to tell a reorder from
a filing action.

On a narrow viewport the sidebar is a drawer that slides out of view rather than
unmounting, so its rows answer every query while nothing on them can be tapped.
The scenario helpers open it through the chat header the way a person does,
which is what lets the touch and keyboard scenarios run in the mobile project.

* 📝 docs: Say What the Kept Overflow Control Actually Costs

The comment beside `hasInteracted` claimed virtualization bounded the controls a
hovered row leaves mounted. It does for the chats list, which drops a row as it
scrolls out, but not for the Pinned section: that one mounts every pinned row at
once, so a pointer crossing it leaves one `ConvoOptions` per row it touched,
standing until the section unmounts. The bound there is the number of chats the
user pinned, which is worth stating rather than implying it is zero.

* ⏱️ test: Give the Sidebar Scenario Hooks the Time Their Seeding Takes

`test.setTimeout` called inside a test body does not reach the hooks around it,
and the slow part of these scenarios is in a hook: seeding a pinned list, then
reloading and waiting for the section. On a loaded machine the `beforeEach` hit
the default 30s and failed a scenario whose own budget was 60s. The timeout is
configured once per file instead, and the per-test calls it duplicated are gone.

* ⏳ test: Allow for the Cold Start the First Sidebar Scenario Pays

The first test of a run loads the app for the first time against a database that\nmay be a network hop away, and under reviewctl verify that first load pushed the\nseeding hook past 60s. The file budget is 120s and the navigation waits are 30s,\nwhich is the cold start plus room, not a licence for a slow assertion: every\nassertion here still resolves in single-digit seconds once the app is warm.

* 🧱 test: Tell an Unlaid Sidebar From a Closed One

The helper that brings the sidebar on screen read a missing bounding box as "off\nscreen" and reached for the chat header's opener, which desktop widths do not\nrender: the click waited out the whole test budget while the panel it was waiting\nfor was already open beside it. Placement is now three states — on screen, slid\nout of view, not laid out yet — and only a drawer that is really closed, on a\nviewport that really has an opener, is clicked.

* 🧮 fix: Keep Each Pinned Kind in Its Own Slots, and Never Report a Pending Write as Done

Two ways the drop and order bookkeeping could still move a row nobody touched.

A stored order written before the two kinds were kept apart can interleave them,
and `mergeVisibleOrder` substituted visible keys across that interleaving. With
part of the pinned list still draining, reordering two visible chats could carry
an undelivered chat across the favorite between them, so the row moved within its
own group once the rest of the list arrived. The stored order is now grouped the
way the section reads it back, and each kind is substituted inside its own run.

`useAssignDroppedConversation` treated a pending assignment's destination as an
outcome: dropping the same chat on Chats again while its first write was still in
flight matched `effectiveProjectId`, reported success, and unpinned the chat — and
if that first write then failed, the chat stayed in its project with its pin gone.
A write already heading where the drop asks now reports "not filed", so the drop
that started it keeps ownership of what follows.

Covered by `dnd.spec.ts` (`keeps each kind in its own slots when a legacy order
interleaves them`), `unpinDropped.spec.tsx` (`does not unpin again while an
assignment to the same place is in flight`) and the `PinnedSection` hidden-key
test, which now states the normalized order it expects.

The pinned scenarios also wait for the rows to advertise their reorder shortcut
before dragging: a move is refused until the saved order has arrived, so a drag
issued before that passed or failed on how fast the query answered.

* 🔗 fix: Wait for the Write a Drop Asked For, Whoever Started It

The pending-assignment guard assumed the write in flight belonged to another\ndrop, which would unpin the chat when it landed. The row menu populates the same\nmap and unfiles a chat without unpinning it, so a drop onto Chats made while that\nmenu action was in flight did nothing at all: an accepted drop left the chat in\nthe Pinned section.\n\nA pending entry now only stops this path from reading \ as\n"already there". The drop issues its own write and waits for that one; the\nmutation queues writes per conversation, so it lands after the one already out\nand repeats what it asked for, which the server takes as the no-op it is. The\nunpin still runs only on a write this drop saw succeed.

* 🛰️ fix: Let the Server Say Whether a Dropped Chat Is Out of Its Project

Three rounds of review found three ways the same guess went wrong, so the guess
is gone. Deciding locally whether the filing half of a Chats drop "already
holds" meant reading one of three things that are not the answer: a pending
write, which is a request that can still fail; a cached project, which another
tab may already have changed; or the drag item's own copy, older still. Each one
could let the drop unpin a chat that stayed in its project, and a chat that is
pinned nowhere and filed in a project is in neither list the sidebar shows.

The drop now always sends the assignment and acts on what comes back. Repeating
an assignment the chat already has is the no-op update it looks like, and the
mutation queues writes per conversation, so this one lands after anything
already out. The success notice reads the two project ids in the response rather
than the fact that a request was sent, so a write that moved nothing stays
quiet — which is what a pinned chat with no project gets when it is dropped on
Chats.

`unpinDropped.spec.tsx` covers the three: the unpin waits for the write, a failed
write keeps the pin, and a chat already out of every project is still confirmed
with the server and unpinned without announcing a move that did not happen.

The scenario drag helper also steps its pointer more finely. The list shifts rows
as it reorders and reads the pointer offset when the hover fires; a pointer that
arrived in one jump got a single reading, sometimes taken mid-render, and the
reorder it asked for did not always happen.

* 🧷 test: Press Only a Row the List Has Connected for Dragging

The pinned list disconnects its drag sources while the saved order reconciles,\nand a press on a disconnected row is not a refused drag — it is a click, so the\nrow opens instead of moving. Against the lab database, where that reconciliation\ntakes long enough to overlap the gesture, the Chats-drop scenario failed with the\ndragged row still pinned and now the active chat.\n\nreact-dnd marks a connected source \, so the helper waits for that\nbefore pressing.
* 🧿 fix: Bind Login Flows to the Browser That Started Them

Social OAuth logins (Google, Facebook, GitHub, Discord, Apple) now store a random state in an HttpOnly cookie scoped to the callback path and only exchange the returned code when the callback carries that state. Apple's form_post callback uses a SameSite=None cookie, and passport-apple's own fixed state no longer bypasses the store.

Local login, temp-token 2FA verification and admin local login reject requests a browser sent from another origin (Sec-Fetch-Site, falling back to Origin vs Host), trusting DOMAIN_CLIENT and DOMAIN_SERVER. Requests without browser fetch metadata, such as server-side callers, pass unchanged.

The sessionless OAuth callbacks drop failureMessage, which wrote to req.session and turned any rejected callback into a 500.

* 🧿 fix: Configure Login State Cookies and Localize Origin Rejections

- OAuth state cookies take secureCookie and maxAgeMs from the caller; registration.oauthStateTtlMs in librechat.yaml sets the lifetime (default 10 minutes).
- Secure deployments use a __Host- cookie at Path=/ so a sibling subdomain cannot plant a state.
- The cookie keeps up to three pending states, so logins started in separate tabs all complete, and an unmatched callback no longer discards them.
- passport-apple's preset state is dropped by deferStateToStore in packages/api.
- The Origin fallback compares scheme and host against the request itself.
- The same-origin rejection carries ErrorTypes.AUTH_CROSS_ORIGIN, which the login and 2FA screens show as a localized message.

* 🧿 refactor: Sign Login State Against a Per-Browser Binding

- The OAuth state store keeps a random per-browser binding in a __Host- cookie (reused across flows) and signs each state as issuedAt.nonce.HMAC(JWT_SECRET, provider, binding, issuedAt, nonce). Nothing is stored per flow, so any number of tabs complete, each state expires on its own clock, and concurrent starts no longer overwrite each other.
- The experimental server entrypoint passes its app config to configureSocialLogins.
- The same-origin guard also trusts an explicitly configured ADMIN_PANEL_URL.

* 🧿 fix: Keep Separate Bindings for Racing First Logins

A browser without a login-state binding now gets one under a cookie name with a random suffix, so two tabs that start their first login at once each keep their own binding. Later starts reuse an existing binding, and a callback is accepted when its state was signed for any binding the browser presents.

* 🧿 fix: Sanitize Request-Derived Values in the Cross-Site Rejection Log

The same-origin guard logged the request Origin and path verbatim. A non-browser client fully controls the Origin header, and the console transport uses a printf format that does not escape control characters, so a crafted value could forge log lines. Strip ASCII control characters (CR, LF, NUL and the rest) before logging, matching the pattern apiNotFound already uses.
…ver (#15489)

A conversation whose sender is a label someone configured shows that label as the
header's name. Since #14851 the header's hover crossfade swaps the raw `message.model`
back in, and the sr-only "Model:" text announces it regardless of hover.

Ask the message, not the settings. `resolveSender` writes an agent's name, then the
`getEphemeralSender` chain — a preset's `modelLabel`, a model spec's `label`, an
endpoint's `modelDisplayLabel` — and falls back to `getResponseSender` only when none
is set, so the persisted `sender` is the one thing that records which it was. The new
`isConfiguredSender` compares it against what `getResponseSender` would produce for the
same endpoint and model: equal means the header is showing a model-derived name and the
hover may reveal the model, different means it is standing in for one.

That settles three things a settings lookup cannot. Labels that live in config a caller
may not hold — a spec that sets `label` and leaves `preset.modelLabel` unset is the
ordinary shape, and the share publisher cannot read the spec list. Labels an endpoint
ignores — Anthropic keeps writing `Claude` whatever `chatGptLabel` says, and it matches
here without anything enumerating which endpoints honour what. And labels changed since
the message was written — the row still shows the sender it was written under, so
clearing a label must not start revealing the model behind the rows that carry it.

Equality is the test, so the ordinary way to be wrong is a stored name that no longer
matches what the current heuristics produce, which withholds a model rather than
revealing one. Three cases are decided before any comparison: a user turn is headed by
its author and has no model to withhold; agents and assistants are named by whoever
authored them, so the header shows that name whether or not the response stored a
sender; and an endpoint that cannot be named — an older message saved without one —
says nothing either way, where reading silence as a label would withhold the model from
every unlabelled row it reached.

`getHeaderHoverLabel` takes that answer and produces no hover label when it is true, so
the header renders the plain label with no swap and no sr-only model text. Without a
configured sender the behaviour is unchanged.

- Chat rows: `useMessageActions` and `useMessageHelpers` decide once per row from the
  message they already hold, so MessageRender, MessageParts and ContentRender consume it
  like any other derived display state.
- Share links: the view has no conversation in scope, so `getSharedMessages` reads the
  answer off the very messages it is returning, collected in the pass `anonymizeMessages`
  already makes rather than a second traversal. Only messages the link actually publishes
  have a say, and nothing about the link is re-derived from the conversation afterwards.
  `ShareContext` — already the carrier for share-scoped values such as `shareId` — hands
  it to the header.

The flag travels rather than the label text: each surface only needs the decision, and
the text is already carried by `message.sender`, which the share content preflight
inspects.

Search rows are left alone. `GET /api/messages?search` returns each hit's stored sender
and endpoint but overwrites `model` with the conversation's current one, so a row cannot
be classified from what it carries without changing what that payload means.
* 💳 fix: Reserve Balance for In-Flight Requests

Admit each balance-checked request against the credits that other
in-flight requests have not already reserved, and hold its prompt cost
on the balance record until the turn settles. Apply a due auto-refill in
the same fenced write so one refill window credits exactly once.

- data-schemas: reserveBalance / releaseBalanceReservation replace
  createAutoRefillTransaction; the write is a compare-and-swap on
  tokenCredits, reservations and lastRefill (no pipeline updates)
- balance.reservations is select:false so existing reads never see it
- checkBalance returns a reservation handle; BaseClient and the
  assistants controllers release it once the turn settles
- balance.reservationTtlMs bounds how long an unreleased reservation
  counts (default 30 minutes)

* 🧮 refactor: Admit Reservations Without Contention and Record Refills Durably

- admission is a conditional $push/$inc against a reservedCredits total,
  so concurrent admissions of a funded balance commit on their first write
- expired reservations are pruned per element with their credits
- auto-refill is fenced on every setting its decision reads and leaves a
  pendingRefill ledger marker in the same write; any later read records the
  transaction under the fixed id and clears the marker
- a missing record is created with $setOnInsert inside reserveBalance
- reservation lifecycle moves to packages/api (createBalanceReservations,
  withBalanceReservations); BaseClient releases once usage is recorded

* 🪪 fix: Resolve Each User to One Balance Record

- balance records a creator inserts are keyed by the user id, so creators
  racing on a missing record converge on one document; every balance read
  and write resolves a user to their oldest record, so existing duplicates
  stay inert
- upsertBalanceFields takes insert-only fields; the balance-config
  middleware, schedules and lazy initialization pass the starting credit
  through them so it never overwrites a record another writer created
- the auto-refill write is also fenced on reservedCredits
- the scheduled-chat balance pre-skip counts reserved credits as unavailable

* ⏳ fix: Count Only Unexpired Reservations in Scheduled Balance Checks

findBalanceByUser with includeReservedCredits now totals the reservations
that have not expired, so a reservation left by a crashed request stops
pre-skipping scheduled chats at its expiry instead of when a later
reservation write prunes it.

* 🔁 fix: Renew Balance Reservations While Their Request Runs

A reservation is renewed every half TTL until it is released, so only a
reservation whose process stopped expires. Pruning removes a reservation
only while it is still expired at the write, so one renewed after the
pruning read survives.

* 🛟 fix: Keep Reservations Alive Through Renewal Failures and Lost Acks

- renewal reschedules itself: every half TTL, capped to the timer range,
  and a failed renewal retries within a tenth of that window
- an admission write that errors removes its possibly committed
  reservation before rethrowing

* 🛑 fix: Hold a Stopped Turn's Reservation Until Its Charge Lands

- an aborted turn lets its reservations lapse after a minute (or the TTL)
  instead of releasing them, so the Stop request's charge lands before the
  credits are admitted again
- balance.reservationTtlMs has a 10 second minimum, enforced in the schema
  and at runtime, so renewal stays bounded

* ↩️ revert: Release a Stopped Turn's Reservation When the Turn Settles

An aborted turn releases its reservations when it settles, the same as
every other exit, instead of holding them for a fixed lapse window that
refused funded requests after every Stop. The minimum reservation TTL
stays.

* ⏸️ fix: Hold Assistants Reservations Through Background Run Retries

An in-progress Assistants run continues in the background after the
handler responds; the turn's reservations now stay held until that run
settles (createBalanceReservations().holdUntil).

* 🔁 fix: Apply an Auto-Refill Once per Admission

A refill interval that is due again the moment it is applied, such as a zero
interval, let one admission refill repeatedly until the retry bound. Each
admission now applies at most one refill, as the previous check did.

* 🧹 fix: Prune Expired Reservations in One Write

An admission that found expired reservations issued one write per hold, so a
backlog left by a stopped process fanned out into as many concurrent writes.
The prune is now a single write fenced on every hold it removes; a hold renewed
after the read makes the write miss and the admission re-read.

* ⏱️ fix: Renew Reservations From Their Stored Expiry

The next renewal was scheduled half a TTL after the previous write settled,
and the first one half a TTL after the admission returned, so a slow write
could let a live hold expire before its renewal. Renewals are now scheduled
from the expiry the last write stored.

Expired holds are pruned as one bulk write of per-hold fenced updates, so a
hold that changed after the read is skipped while the rest are removed, and
the admission no longer spends a retry on the whole prune.
…#15887)

A rerun replays the edited message's parent as the turn's user message, so a
response with no user turn behind it has nothing to submit. The editors gated the
action on `parent?.isCreatedByUser !== false`, which treats an unresolved parent as
replayable: a reply the import left at the root kept an enabled Rerun, bound to
Ctrl/Cmd+Enter, that both handlers then refused with no feedback.

Both editors now resolve that parent through `findRerunParent`, at render and again
at submit, so the button and the submission cannot disagree. Where the action is
withheld the footer's existing status slot says why instead of going blank. Save,
Cancel and Ctrl/Cmd+S are untouched.

The hover row carried the same shape: `parentIsUserMessage` was undefined for an
absent parent, leaving Regenerate and Continue enabled while `regenerate` only
logged. It now separates a missing thread — a search row, which still withholds
nothing — from a parent that is not in the thread.
…15884)

npx joins the command into one shell string, and Linux rejects a single
argv string over 128 KiB (MAX_ARG_STRLEN), so past ~2,200 changed files
the ESLint and Prettier steps exited 249 with no output. The binaries
npm ci installed take the file list as separate arguments.
)

Profiling a full-tree lint showed import/no-cycle at 80% of rule time and
prettier/prettier at 14%. Cycles are already checked by
config/circular-deps.mjs over the bundler graph and formatting drift by
the per-file Prettier step, so the sweep now switches both rules off,
skips the base sweep when eslint.config.mjs is unchanged, and the config
drops import/no-cycle along with parserOptions.project blocks that fed
no type-checked rule. Findings for every remaining rule are unchanged.
* 🪟 fix: Opaque Header Controls Over the Chat Gradient

The mobile sidebar toggle let the conversation show through itself: the chat
header is a gradient fading to transparent with messages scrolling under it,
and the shared `header-action` variant carried `bg-transparent` while every
other control in that row sits on `bg-presentation`.

* 🔒 fix: Keep an Explicit Button Shape Over the Header Compound

A compound variant is emitted after the `shape` recipe, so the `header-action`
+ `size: 'sm'` corner repair outranked a caller that asked for
`shape="theme"` or `shape="round"` — `cn` kept the later `rounded-xl` and
dropped the requested radius. Gate it on `shape: 'unset'`, the way the
`subtle` compound already is, so it only repairs the default geometry.

Adds the mobile header row's acceptance scenarios to the mock harness: the
toggle's interior does not change as the conversation scrolls under it, its
fill and corner match the new-chat and overflow controls beside it, and the
drawer's close toggle is the same control by fill, corner and tap target.
All three fail against the previous transparent fill.

* 🧪 test: Rewind the Header Scenario Before Scrolling It

The scroll scenario sampled the toggle from wherever a freshly opened
conversation happened to land. That is the app's business — the newest message
with auto-scroll on, the top of a seeded transcript without it — and a landed
end leaves a wheel delta in that direction nothing to move, which would fail the
"the conversation did not move" guard rather than measure the fill.

Rewind to the top first, then bring the transcript down under the header, so the
wheel always has a known distance to travel. The guard stays: a run where the
conversation does not move still fails instead of passing vacuously.

* 🧪 test: Read the Header Toggles With the Pointer Off Them

`click()` leaves the pointer on the opener's coordinates, and the drawer slides
its own toggle onto those same coordinates, so one of the pair could be reading
`hover:bg-surface-active-alt` while the other sat on `bg-presentation` — an
equality that depended on when the 300ms slide settled.

Move the pointer off both controls, wait for the drawer, and assert the pointer
really is clear: each surface now reports its own `:hover` state, so a run that
measures a hovered control fails on that rather than on the colour.
* 💰 fix: Record Token Usage for Stopped Agent Turns

The agents Stop route only signals the abort; the run's cleanup skipped recordCollectedUsage on an aborted signal, trusting an abort middleware that is mounted only on the assistants abort routes. Stopped and error-aborted agent turns therefore wrote no transactions and never debited the balance.

The run now records its collected usage on every exit, labelling a stopped turn's transactions with context 'abort'. The owner of the run charges, so a cross-replica Stop is still billed exactly once.

* 💰 fix: Make the Run the Single Charger for a Stopped Turn

The legacy assistants-mounted abort route still billed a non-assistants job — provider usage via spendCollectedUsage, or a text-count fallback — on top of what the run records on exit, and its array clear ran only after its own awaited write, so the two could race even on one replica. The route now only stops and persists; the run records its usage once, labelled through resolveRunUsageContext in packages/api.

* 💰 fix: Keep the Text-Count Fallback From Re-Billing Recorded Usage

BaseClient falls back to text-count billing whenever the recorded usage has no positive output count, so a stopped call that reported input tokens and no output was debited by recordCollectedUsage and then charged its estimated prompt again. AgentClient.recordTokenUsage now returns early once provider usage was recorded (hasRecordedProviderUsage in packages/api); an all-zero report still falls through to the estimate.

* 💰 refactor: Lift Fallback Token Billing Into packages/api

recordFallbackTokenUsage owns the whole fallback operation — the recorded-usage guard, the estimate spend, the reasoning row and the error boundary — taking spendTokens as an injected dependency. AgentClient.recordTokenUsage only builds the transaction metadata from the client and calls it.

* 💰 fix: Label Fallback Billing From the Run's Stop State

BaseClient invokes the estimate fallback without a context, so the sole row for an initial turn stopped before any provider usage was labelled 'message'. recordTokenUsage now defaults the context from the run's own abort signal.

* 💰 refactor: Default the Fallback Billing Label in packages/api

recordFallbackTokenUsage takes the run's raw abort state and derives the transaction label itself; AgentClient.recordTokenUsage passes the signal through and no longer chooses the context.

* 💰 fix: Guard Fallback Billing on the Collected Primary Entries

The stream aggregate recordCollectedUsage returns takes its input from the first primary call only, so a later cancelled call that reported input alone was billed yet invisible to the fallback guard, which then charged the estimated prompt again. recordFallbackTokenUsage now also consults the collected entries themselves (hasRecordedPrimaryUsage), ignoring summarization, subagent and sequential usage; AgentClient passes its collected entries through.
…15883)

* 🧷 fix: Mark a Failed Compaction So Its Rerun Controls Stay Withheld

A manual compaction that failed with an error part carried no marker: the
server stamped `initiatedBy: 'user'` only on a summary that produced text and
returned early once an error part existed. On a branch ending in a user
message, the resulting turn had no marker and a user parent, so the hover row
kept Edit, Regenerate and Continue — and using one answered that user message
instead of retrying the compaction.

`markCompactionOutcome` now marks whichever part carries the outcome, summary
or error, and `isUserInitiatedCompaction` reads both. The marking moved from
`api/server/controllers/agents/client.js` into `packages/api`, where the
behavior belongs; the controller keeps the call.

* 🏷️ refactor: Type the compaction spec helpers with SummaryContentPart

* 🧯 fix: Record a Marked Failure When a Compaction Produces Nothing

A compaction run that produced neither a summary nor an error part threw
COMPACTION_FAILED out of `sendCompletion`. The turn was then saved by
`saveErrorTurn`, which writes only top-level `text`/`error` and no content, so
the row carried no compaction marker: on a user leaf the hover row kept
Regenerate, and using it answered that user message.

`markCompactionOutcome` now records the typed failure as a marked error content
part instead, so the turn streams and persists like every other failed
compaction. A cancelled run still fails as a typed error, leaving the abort
path to own the turn. `saveErrorTurn` marks the compaction rows it still writes
for failures that are rethrown rather than recorded as content.

Verified end to end by two new mock scenarios that run Compact context against
a summarizer returning nothing, live and after a reload.

* 🧪 test: Run the Empty-Compaction Scenarios Against a Real Compaction

* 🧱 refactor: Resolve Failed-Turn Compaction Content in packages/api

`saveErrorTurn` decided in CJS whether a failed row carries compaction
identity. The decision moves to `resolveFailedTurnContent` in
`packages/api`, which returns the content fields a failed turn is persisted
with — the marked failure for a compaction, nothing for any other turn — and
`request.js` spreads the result.

* 🧹 fix: Keep an Unusable Summary Out of a Failed Compaction's Turn

History loading accepts any nonempty summary as the conversation's checkpoint
(`BaseClient.findSummaryContentBlock` ignores `failed`), so a compaction that
streamed partial text before failing would have persisted that truncated text
beside its recorded failure and replaced the history it failed to summarize.
The fallback now drops the unusable summary, leaving the typed failure as the
turn's whole outcome.

The empty-summarizer scenarios switch the shared fixture summarizer to blank
output, so the reset moves to `afterEach` and runs even when the helper throws
before its caller's `finally`.
* 🪧 fix: Emit the Configured-Footer Answer With the HTML Shell

The chat surface lays out against whether the deployment configured a footer
(customFooter, privacy policy, terms of service). The client answered that from
localStorage — whatever the last resolved `/api/config` recorded — so the answer
was a guess on a first-ever visit and could be stale for a deployment whose
configuration changed.

The backend already knows when it serves client/dist/index.html, so it says so
there: `injectConfiguredFooterBootstrap` writes `hasConfiguredFooter` into
`window.__LIBRECHAT_CONFIG__` ahead of the app's own scripts, once at boot from
the base app config, and the client reads it on its first render instead of
remembering the last answer. The `configured-footer` storage atom is gone.

Client and server share one predicate (`hasConfiguredFooter` in
librechat-data-provider) so the two cannot disagree, and the shell injection is
generalized (`injectBootstrapConfig`), with the query-devtools flag now going
through it.

* 🧷 fix: Keep Bootstrap Values Literal and State the Shell's Contract

`injectBootstrapConfig` passed its script as a replacement string, so a `$&`,
`$1` or `$'` inside an injected value was expanded by `String.replace` instead
of reaching the client. Both insertion points take a replacement function now,
and the spec reads its values back off the global through an explicit result
type rather than an unchecked dictionary.

The shell answers from the deployment's own configuration, which a per-tenant,
role or user override of `interface.privacyPolicy` or `termsOfService` can
disagree with — `/api/config` resolves the caller's and the client prefers it.
That contract was overstated in three comments ("a deployment-lifetime fact",
"the same one the config will give"); they now say what the answer is and what
resolves the rest. Two scenarios cover the disagreement end to end: a document
served without the answer, and a policy link only this caller's config carries.
* 🧾 fix: Count the Tool Results a Tool-Limit Stop Retains

Context snapshots reach the client only through the SDK's pre-invoke
`ON_CONTEXT_USAGE`, so the results of the tools a call requests are never in that
call's snapshot — the next call's snapshot carries them as kept-message context.
A run that stops at the tool-call limit makes no next call, so the tool result it
retains lives in the response and in no snapshot: the gauge reported
`(budget − remaining) + completedOutputTokens` and left the retained result out
of used tokens and out of the tool-call share until the following turn.

The save path now counts those results with the run's own tokenizer and persists
them as `retainedToolTokens`, a second post-snapshot delta alongside
`completedOutputTokens` rather than a number folded into the provider-reconciled
`messageTokens`. `resolveRetainedToolTokens` owns the rule that only a tool-limit
stop retains anything, and the snapshot handler records where its content ended
so the count starts at the right boundary.

Counting had to avoid `Tokenizer.getTokenCount`, whose fallbacks would have put a
guess inside exact accounting: above 4 KiB it returns byte length, several times
the real count on ordinary text, and it estimates from character length while an
encoding loads. `countExactTokens` tokenizes in bounded slices cut on code-point
boundaries and returns nothing at all when the encoding is cold, so an
uncountable result withdraws the figure instead of inflating it.

The client adds the field to used tokens, subtracts it from the runway headroom
and widens the tool-call share, in the live snapshot after finalization and in
the persisted blob after a reload.

* 🧹 style: Wrap the Retained-Counter Assertion as Prettier Requires

* 🧮 fix: Address the Review of the Retained-Tool Count

Three findings from the first round, each a real defect in how the figure was
produced rather than a style point.

The boundary was a content index recorded mid-run, but completion reshapes the
array — skill cards are unshifted onto the front and `hide_sequential_outputs`
replaces it with a filtered one — so a saved index no longer means the same
position. The snapshot now records the tool-call ids it already accounts for, and
the save path counts the results of the calls missing from that set: ids survive
every reshape, and a filtered-away call is correctly left out.

Counting in 4 KiB slices was not exact either: a BPE merge spanning a seam is
charged twice, measured at ~1 token per slice, and the field exists precisely to
be an exact addend. `countExactTokens` now tokenizes the whole input — ~60 ms/MB,
paid once at the end of a stopped turn — and refuses content past 8 MiB rather
than estimating it.

The counter takes its exact-count function instead of reaching for the tokenizer
singleton, so `resolveRetainedToolTokens` owns the default (the run's own
encoding) and a caller or test can supply another. That also removes the mock of
global state from the specs.

`compactionReclaim` now includes the retained result in the total it subtracts the
kept exchange from. `latestExchangeTokens` already counts that result on the
other side, so leaving it out subtracted content the total never carried and
understated the savings — to zero on a large final result.

* 🧯 fix: Bound One Turn's Retained-Result Tokenization

The tokenizer refuses a single result past 8 MiB, but a final call that requested
several tools in parallel would pay that bound once per result. The counter now
holds a budget for the whole turn and withdraws its figure past it, so the save
path cannot be made to tokenize an unbounded pile of output.

* 🎚️ feat: Configure the Retained-Result Tokenization Budget

The exact count the gauge adds costs ~60 ms/MB of retained tool output, and the
ceiling on that work was hard-coded in two places. It is now one lever:
`endpoints.agents.maxRetainedToolCountChars`, defaulting to the 8 MiB that
reproduces today's behavior, shared by the schema and the save path through
`DEFAULT_MAX_RETAINED_TOOL_COUNT_CHARS`. Deployments whose tools legitimately
return more can raise it; slower hardware can lower it, or set `0` to withhold
the figure entirely.

`Tokenizer.countExactTokens` no longer carries a bound of its own — the caller
owns the budget — and `resolveRetainedToolTokens` passes the configured value to
the counter, which spends it across all of a final call's parallel results.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
The image generation and edit label carried `progress-text-content` beside
`tool-status-text`. The former sets `font-size: inherit` and is declared later
in style.css, so it won the cascade. Outside the `progress-text-wrapper` that
class is meant for, the label inherited the page's 16px/24px instead of the
row role's 0.9x of the chat font size. It rendered larger than its sibling rows
at the default size and ignored the font-size preference at every other size.
…le (#15900)

The image generation and edit row set no text color, so its label inherited
`text-text-primary` from the message container. Every sibling row renders its
label in `text-text-secondary`, including this row's own icon. A finished,
failed or cancelled image label, and a running one under reduced motion where
the shimmer paints `currentcolor`, read brighter than the rows around it.

The row container now carries `text-text-secondary`, the way the generic tool
row's wrapper and the tool group header set their color.
* fix: retry OpenID discovery after startup failures

* fix: keep OpenID background retries resilient

* refactor: move OpenID retry policy into API package

* 🔁 feat: Expose OpenID Discovery Retries Through Registration Config

* 🧭 refactor: Own OpenID Discovery Registration and Defaults in TypeScript

---------

Co-authored-by: insoln <insoln@ya.ru>
* 📐 fix: Accept Custom WIDTHxHEIGHT Sizes in OpenAI Image Tools

* 📐 fix: Drop Local Digit Bounds From the Image Size Pattern
`maxWorkers: '50%'` resolves to a single worker on the 2-vCPU GitHub-hosted
runners that private repositories get, and Jest then runs in band. Any test
file that leaves a handle open (a listening `http.Server`, a timer, a DB
connection) keeps the main process alive after "Ran all test suites" until the
job's `timeout-minutes` cancels it, so a run with every test passing still
fails. Public runners have 4 vCPUs, where `'50%'` is already two workers and
the leaked handle dies with the worker process.

Move the worker count into `config/jest.workers.cjs`, shared by all six
workspace configs: local runs keep `'50%'`, CI uses `max(2, floor(cpus / 2))`.
On 4-vCPU runners the value is unchanged; on 2-vCPU runners Jest now uses
worker processes the same way it does upstream.

Co-authored-by: Danny Avila <danny@librechat.ai>
@pull pull Bot locked and limited conversation to collaborators Sep 14, 2026
@pull pull Bot added the ⤵️ pull label Sep 14, 2026
@pull
pull Bot merged commit 7fe9a45 into innFactory:main Sep 14, 2026
5 of 6 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants