[pull] main from danny-avila:main - #261
Merged
Merged
Conversation
* 🧭 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 : )