Skip to content

Sync upstream v0.8.8-rc2 into apro-deploy - #74

Closed
busla wants to merge 767 commits into
apro-deployfrom
sync/v0.8.8-rc2
Closed

busla wants to merge 767 commits into
apro-deployfrom
sync/v0.8.8-rc2

Conversation

@busla

@busla busla commented Sep 9, 2026 •

Copy link
Copy Markdown

Summary

This PR synchronizes apro-deploy with upstream release candidate v0.8.8-rc2. It restructures our fork customizations into a clean 2-layer commit stack designed for zero-friction rebase resolution when upstream releases the v0.8.8 stable branch.


Fork Code Reductions (Audit Findings vs Upstream v0.8.8-rc2)

Based on our independent audit of upstream v0.8.8-rc2, we significantly reduced our fork's custom footprint:

  1. Hardcoded Token Aliases (100% Eliminated):

  2. Icelandic Locize Translations (>99% Reduction):

  3. Sparse Array / Null Content Parts Crash:

  4. MCP Elicitation Monolith Decomposed:

  5. Server-Level MCP deferLoading:


2-Layer Commit Stack Architecture

Layer 1: Upstream PR Commits (Cherry-Picked)

Cherry-picked directly from active upstream PR branches so git patch-id resolution automatically drops them when upstream merges them into v0.8.8:

Commit PR Description
aea7126e9 #15314 ✨ feat: Serve a Custom Endpoint Its Declared Models Intersected With the Fetched Catalog
a26ce122c #15311 🏷️ feat: Custom Endpoint Model Display Labels
9e3b81a18 #13951 🦥 feat: Server-Level deferLoading Default for MCP Servers
728f849d9 #15550 🛬 feat: Resume MCP Tool Calls After URL Authorization (#15550)

Layer 2: Fork-Specific Custom Code (Atomic Commits)

Isolated into discrete, focused commits with zero bleed into upstream code:

Commit Scope Description
658412652 Core Fix 🛡️ fix: Strip null content parts on message load to prevent formatAgentMessages crash (#64)
ce4106799 Auth feat(auth): auto-refresh OIDC tokens when session is empty (openIdJwtStrategy.js, AuthService.js, and test specs)
aafe211c6 Config fix(config): filter modelSpecs by user availability in /api/config
a0d634c06 Models feat(models): allow empty model list declaration, complement filter, and avoid bans for unserved models
a34e130da Endpoints fix(endpoints): prefer declared custom endpoint over case-folded builtin (from PR #72 / closed upstream LibreChat-AI#15310)
04d3e9b8b i18n i18n: update translations from Locize
32d91f513 MCP feat(mcp): form-mode elicitation and interactive schema validation
a4d086713 Tests test: align test mocks and token parity for v0.8.8-rc2 (ModelPanel.test.tsx, EndpointModelItem.test.tsx, format.ts)

ECS Deployment Diagnosis & Fix

During rollout on AproChat/aprochat-api in eu-west-1, the service failed with:
Essential container in task exited (exitCode: 1)

  • Investigation: By launching a diagnostic task with ECS Execute Command enabled, we captured the startup error:
    /app/api/server/services/ToolService.js:1366
    <<<<<<< HEAD
    SyntaxError: Unexpected token '<<'
    
  • Resolution: A leftover git merge conflict block in api/server/services/ToolService.js (lines 1366-1371) was resolved to pass both getServerDeferLoading (fork) and refreshMCPServerTools (upstream).
  • Validation: Verified node --check passes with 0 errors across all 366 JS files in api/, all 113 tests in ToolService.spec.js pass, and tsc --noEmit passes cleanly across all packages.

danny-avila and others added 30 commits August 23, 2026 02:06
* fix: report complete agents api usage

* fix: preserve invoked usage context

* test: cover absent usage context

* fix: type responses usage finalization

* fix: preserve reasoning usage aliases

* fix: declare reasoning usage alias
* perf: enable agent context count reuse

* fix: declare token counter return type

* fix: initialize cached token counters

* style: sort token counter imports

* fix: Keep cached token counts exact
…#15133)

* feat: continue completed subagents as chats

* chore: sort continuation imports
The info and remove buttons on each Tools/Skills row passed `size-6 p-0`
through className without a `size` prop. tailwind-merge 1.14.0 has no
`size-*` group, so `size-6` never conflicted with the default size
variant's `h-10 px-4 py-2` and the buttons rendered at 40px. Because they
sit at opacity-0 until hover, the rows read as 52px of mostly empty
padding.

Pass size="icon-xs" so the recipe replaces the default outright. Rows go
from 52px to 40px.
…ibreChat-AI#14990)

* feat: leave a dismissable strip of chat beside the mobile drawer

The drawer took the whole viewport, so opening it read as a screen change
rather than a layer over the conversation, and the only ways back were the
header button and a swipe.

It now stops at 80% and the chat stays visible behind a scrim, which is
itself the dismiss target: tapping it closes the drawer and returns to the
conversation, which never navigated away. The scrim renders as a sibling
of the pane rather than inside it, because the pane is inert while the
drawer is open and would swallow the click. Drawer width and pane travel
derive from one constant so they cannot drift.

Closing had to change with it. A programmatic close repositioned the pane
instantly, which was invisible only because a full-width opaque drawer
covered the jump; with a strip on screen that jump lands in plain view, so
both surfaces animate together, the motion the drag path already produced.
The spec that pinned the old reveal is rewritten to pin this.

The easing also changed: the previous curve spent its last third of the
duration on a few percent of the distance, which read as the panel
sticking just before it landed, most obvious on close.

Three things the scrim has to respect, each found in review:

- It routes through useSidebarToggle rather than writing the atom, so the
  slide still starts imperatively and a large conversation cannot stall it.
- It drops its fade under prefers-reduced-motion, matching the snap
  kickDrawerAnimation already performs.
- It stays the pointer target until the close animation settles, derived
  from the committed state so every close path is covered, and cleared on a
  timer so a scrim unmounting at the breakpoint cannot strand it. Focus
  returns to the drawer's opener once the closed state commits, since the
  scrim goes aria-hidden and untabbable.

* fix: close the mobile drawer predictably from every path

Move the close handling out of Root into useDrawerDismiss, which fixes three
things the split scrim-owned version got wrong:

A breakpoint crossing derives the drawer closed with nothing to animate, so
narrowing a window or rotating a tablet armed the pointer guard and left a
transparent full-screen scrim swallowing taps for 300ms.

The scrim stays the pointer target through that guard, where the state has
already committed. A tap there closed again, a no-op that never reached the
focus handoff, stranding the restore flag to fire on a later close.

Focus was only restored when the scrim itself closed the drawer, and only to
the header opener. Closing from the drawer button or Escape left focus in a
subtree that goes inert, and routes that render no opener left it on the
scrim once it went aria-hidden. Every close path now restores, to the opener
or the pane, and only when the close is what dropped focus.

* style: sort imports in the new drawer hook

* fix: reclaim focus from the scrim when Escape closes the drawer

The drawer's Escape handler closes it without going through the scrim, so a
keyboard user who tabbed there kept focus on a button that becomes
aria-hidden and untabbable. Inert drops focus to the body by itself;
aria-hidden does not, so it has to count as lost too.

* feat: make the mobile chat strip a setting, off by default

The drawer covering the full width and closing by swipe stays the default.
Turning the setting on stops it short of the edge, leaving a strip of the
conversation visible that also closes the drawer when tapped.

Both surfaces read one custom property for how far the drawer opens, so the
value can change at runtime without threading a number through the swipe
gesture, and their travel still cannot drift apart. The fallback is the
default, so anything rendered outside the property's scope agrees too.

The scrim moves into its own component, which is what makes its tab order,
aria-hidden and pointer-events states testable.

* fix: keep the reveal close on the default full-width drawer

Making the strip opt-in put the paired close animation on the default path,
where the drawer covers the pane: selecting a conversation then visibly
shifted the chat leftward while the new one committed into the moving layer,
which is the regression the reveal existed to avoid.

The reveal is now chosen from geometry rather than the setting, since it is
safe exactly when the drawer hides the pane, however the width was arrived
at. The drawer also transitions its width, so toggling the setting while it
is open moves both surfaces on one curve instead of jumping the width in a
frame while the pane eases across the transition.

* fix: honour reduced motion when the strip setting changes the width

Changing the setting updates the width custom property directly rather than
going through the snap path, so the drawer eased its width and the pane its
transform for the full transition even for a user who asked for no motion.

The preference now reaches the declarative styles on both surfaces, and the
snap no longer hands an animating transition back afterwards, which is what
left the element ready to ease the next change.

* fix: cover the gesture snap, the close frame and the breakpoint focus

The gesture settle restored the transitions directly rather than through the
reduced-motion handoff, so a swipe left both surfaces ready to animate the
next width change.

The close guard was armed from a passive effect, which runs after paint,
leaving one frame where the pane had dropped inert and the scrim had not yet
taken the pointer back. It is armed in the committing frame now.

Crossing into mobile with focus inside the expanded desktop sidebar drops it
when that subtree unmounts. The guard is still right to stay disarmed there,
since nothing animates, but the focus handoff has to run, so the two no
longer share an early return.

* fix: keep the pointer guard tied to a pane that actually moves

Disabling the strip unmounts the scrim at once while the drawer needs the
whole transition to widen, so a close begun in that window still slid the
pane with nothing holding the pointer. The scrim now stays mounted while a
close is in flight.

Arming that guard is tied to the same geometry the close path already
branches on. A close under a drawer that covers the pane is a reveal, with
the pane already in place, so holding the pointer there would only make the
default configuration feel unresponsive for the length of the transition.

* fix: guard the swipe close and hand focus back off the mobile breakpoint

The guard read the drawer's width to decide whether the pane was moving, but
a swipe animates the pane at any width, so the default configuration went
unguarded through the one close path that does move it. It now asks the pane
itself: the reveal leaves transition none behind, every animated path leaves
the shared transition on it before the state commits.

Leaving mobile unmounts the drawer and the scrim, so focus sitting on either
went to the document. The same handoff runs for that direction, and it now
confirms the opener actually took focus rather than assuming: the opener
stays mounted across breakpoints but is hidden on desktop.

The scrim is imported through the mobile directory's barrel.

* fix: address PR review bot findings

Codex:
- Start the scrim fade with the drawer slide, not the deferred Recoil commit
- Keep the scrim focus ring inside the overflow-hidden shell

* fix: address PR review bot findings

Codex:
- Capture pointer events on the scrim as soon as an open slide starts
- Expire the close guard at the animation deadline, not a fresh 300ms
- Keep pointer capture through a reveal close while the drawer still slides

* fix: hand focus back once the close guard releases

Codex:
- Defer focus restoration until the pane is no longer inert

The guard reapplies inert to the pane in the same commit the close lands,
and both the opener and the pane itself sit inside it, so the handoff was
ejected to the body with no dependency left to re-run it. The release now
flushes before focus moves.

* fix: drop the scrim pointer override when the close slide starts

Codex:
- Clear the opening pointer override on close

The opening kick writes an inline pointer-events override that only the
buffered release cleared, so a dismiss inside that window left the invisible
scrim swallowing taps past the guard. The close now hands capture back to the
classes, which already hold it for the guard's duration.

* fix: carry the focus handoff and the slide's own clocks through a close

Codex:
- Preserve focus when the motion preference changes mid-close
- Keep the scrim armed when an opening is canceled
- Stabilize the drawer width before closing mid-toggle

The handoff is now keyed off the guard releasing rather than the timer, so a
guard cancelled by a dependency change still hands focus back once the pane
sheds inert. A close that cancels an uncommitted open never reaches the
isClosing classes, so it keeps the scrim's pointer override instead of
returning capture that nothing else holds. And the close pins the drawer's
measured width, so a width transition still in flight cannot drive its edge
from a second clock and open a gap against the pane.

* fix: guard a close the committed state never reports

Codex:
- Guard canceled opens when the strip is disabled
- Stabilize the drawer width before an in-flight swipe

A second toggle inside the deferred flip, or an open drag that falls short,
moves the pane without expanded ever changing, so the guard had no transition
to arm from and the default configuration left the pane live as it uncovered.
The slide now reports itself and arms the guard directly, which also makes the
scrim's pointer override unconditional again: every close hands capture back
to the classes.

Claiming a gesture drops the transition, which lands a width still easing
toward the strip target on that target in the same frame, so the touchstart
snapshot went stale and held a gap open between the surfaces for the rest of
the drag. The claim remeasures.

* fix: cover the opening travel and compose the scrim's button

Codex:
- Guard the pane during default drawer opens
- Compose the shared button primitive for the scrim

Recoil's flip is deferred past the opening frames and the closing transition
outlives it at the other end, so the committed state brackets the travel too
late and drops it too early. The guard is now named for what it measures and
arms for any slide the committed state does not report, so the default
configuration covers the pane while the drawer travels over it. Only a close
records the focus handoff; an open hands focus to the drawer's header.

The scrim now composes the shared Button, keeping only the inset ring the
overflow-hidden shell requires.
* feat: custom cron cadence for scheduled chats

Scheduled chats could only be built from four fixed presets, each pinned to a
single hour and minute, so anything outside that shape (twice a day, every 15
minutes, the 1st of the month) was not expressible. This adds a Custom cadence
that takes a raw five-field cron expression.

The cadence schema becomes a discriminated union on `frequency`. A cron row
carries `expression` instead of the hour and minute it cannot represent, since
there is no single hour for `0 9,17 * * 1-5`, and the Mongo schema requires each
field only for the shape that has it: a blanket `required` would reject every
cron write, and dropping it entirely would let a structured cadence silently
fire at 00:00 with a missing hour.

Five fields only. croner also reads a six-field form carrying seconds and a
seven-field form that pins a year, and both are refused. Seconds would promise a
precision the engine does not keep, since it polls on a thirty-second tick and
offsets each schedule by up to two minutes of jitter. A pinned year makes a
cadence that runs out, and every place that computes a next run reads "no next
occurrence" as a cadence it cannot read.

Compilation, validation, next-run previews and interval measurement live in
packages/data-provider so the dialog and the engine share one parser and cannot
drift. The dialog previews the next occurrences, enforces the admin interval
floor and disables its own submit from the same functions the server validates
with, so it cannot offer a Create the API answers 400 to.

The interval floor now covers cron, and measures it twice, taking the smaller.
The nominal gap is probed in UTC and discounted by the same worst-case DST
allowance the structured branches carry, which keeps `0 9 * * *` reporting
exactly what the Daily preset reports. Real elapsed time is then measured in the
schedule's own zone across each of that zone's transitions, because
spring-forward compresses a gap that straddles one: `0 0,12 * * *` in
America/New_York is 11 hours that day, not 12, and a floor between the two would
otherwise be bypassed. The floor ships with the schedules list so the dialog can
mirror it rather than surfacing it as a 400 after submit.

Radio gains a wrap variant, since five frequency segments no longer fit one row
in a phone-width dialog and a translated label can push even a desktop one over.
Its indicator follows the selection across rows; the single-row default is
unchanged.

* fix: mark the cron input invalid when the interval floor rejects it

A floor-violating expression disabled Create and rendered the cadence
message, but the input itself still said aria-invalid=false and its
aria-describedby never reached that message, leaving a screen reader
user with a disabled Create and no stated reason.
…(Stage 2) (LibreChat-AI#15136)

* 🎭 ci: Gate Playwright Lanes and Docker Smokes on Codegraph Selection (Stage 2)

* ci: surface the fail-open reason in the stage-2 select summaries

* ci: log the stage-2 select decision for harvesting

* ci: fail open on fetch failure or truncated file list; type-strict skip decisions (Codex)

* ci: check curl's exit status before honoring a selection (Codex r2)
* feat: choose a schedule's timezone

A schedule's timezone was whatever zone the browser reported when it was created,
and nothing in the dialog could change it afterwards. That is wrong for anyone
who travels, for a shared account, and for a team schedule that should follow an
office rather than whoever happened to open the dialog.

The zone becomes a picker over every IANA zone the runtime knows, with the user's
own zone and UTC pinned first. `Intl.supportedValuesOf` is unavailable on older
engines, so that pinned pair doubles as the fallback list: a user who cannot
browse zones can still keep the one their schedule already uses. Each option
carries its current offset, since a name alone does not tell two similar zones
apart.

A zone change on its own is a timing edit, so it is submitted like one. The
server recomputes the next run whenever the timezone changes and measures the
interval floor against the effective pair, which is what makes `0 0,12 * * *` a
12-hour gap in UTC and an 11-hour one in America/New_York on the day it springs
forward. The dialog now mirrors that: the cron field validates against the
selected zone and the floor is measured in it, so a cadence cannot be accepted
here and refused by the API.

* fix: keep zone-only edits out of the cadence and the zone list findable

A timezone-only edit rode the cadence dirty flag, so the PATCH carried a
cadence rebuilt from the form and could overwrite stored fields the
pickers cannot represent, an API-created hourly's nonzero hour for one.
The floor still validates a zone change as the timing edit it is, but
only touched cadence controls put a cadence on the wire, and the spec
now pins that down instead of only checking the zone.

The picker list also gains the modern IANA names supportedValuesOf
omits (it reports CLDR's legacy canonical forms, Asia/Calcutta for
Asia/Kolkata), each probed against the engine before inclusion, and the
per-zone offset labels are cached per locale so reopening the dialog
stops rebuilding ~400 Intl.DateTimeFormat instances.

* fix: carry the full tzdb rename set into the zone picker

Five names covered the famous renames but Node still accepts and omits
fourteen more modern canonical identifiers (the Argentina provinces,
Indiana and Kentucky city moves, Kathmandu, Asmara, Faroe, Chuuk,
Pohnpei, Kanton, Atikokan). The set is now the tzdb rename list, still
probed per engine and deduped; deprecated links like US/Eastern and the
sign-inverted Etc/GMT forms stay out deliberately, since they duplicate
zones already listed under their canonical names.
…Turns (LibreChat-AI#15138)

* ⚡ perf: Stop Awaiting the Conversation Access Marker Write

Without Redis the CONVO_ACCESS violations namespace is backed by keyv-file,
whose debounced write resolves after ~100ms. validateConvoAccess awaited
that write before calling next(), so the first message to any existing
conversation waited ~100ms before the request was even admitted — once
per conversation per ten-minute window, on every default deployment.

The marker only short-circuits the next check, so the write no longer
gates the request. The same read now stashes the full document on
req.resolvedConversation (null when absent) for downstream consumers.

First-turn ack on an existing conversation: 109ms -> 5ms.

* ⚡ perf: Read the Conversation Once per Chat Turn

A chat turn read the same conversation document four times: the access
check (two fields), the subagent thread guard (full document), agent
initialization (the files field), and the first save. The access check
now reads the full document and leaves it on req.resolvedConversation,
the guard accepts that pre-resolved document instead of re-reading, and
initializeAgent takes the conversation's file refs from it rather than
issuing a separate findOne.

Two serial round trips removed from every turn; the same document still
serves the first save as before.

* ⚡ perf: Remove Duplicate JWT Authentication on Agents Routes

routes/agents/index.js applies requireJwtAuth and then mounts the v1
router at '/', which applied requireJwtAuth again. Every request through
the agents router — chat turns included — ran the passport strategy
twice: two signature checks and two user document reads. The v1 router
is mounted nowhere else; its separately exported avatar router carries
its own auth in files/index.js.

* ⚡ perf: Skip the History Read for Root-Parent Turns and Walk the Tree in O(n)

loadHistory fetched every message in the conversation and then walked the
parent chain from the request's head. For a new conversation — or a new
branch from the root of an existing one — the head is the root sentinel,
which no message carries as its id, so the walk was empty by construction
and the fetch was wasted. It now returns early.

getMessagesForConversation found each ancestor with Array.find inside the
walk, O(n^2) on a linear conversation (~5ms at 1000 messages). A Map by
messageId makes it O(n); first-match semantics are preserved.
…I#15137)

* fix: align subagent activity with chat UI

* fix: preserve subagent activity boundaries

* fix: preserve subagent panel state semantics

* fix: preserve subagent activity metadata

* fix: preserve live subagent event metadata

* fix: scope subagent phases by message step

* fix: retire closed subagent message phases
* feat: weekly schedules on several days

A weekly cadence has always stored `daysOfWeek` as a list, and the API has always
accepted several, but the dialog offered a single day-of-week dropdown. That
picker could only hold `daysOfWeek[0]`, so a multi-day schedule created through
the API read back as running on one day, and the form needed a preservation rule
to avoid collapsing the rest of the set on an unrelated edit.

The dropdown becomes a row of toggles, one per day, so the control can represent
what the cadence already stores. The preservation rule goes with it: there is
nothing left to preserve once an untouched picker shows the real set.

Each pill is a toggle button rather than a checkbox because it renders as one,
and carries the long weekday name as its accessible label since "Mon" reads fine
at a glance but poorly aloud. The submitted set is sorted, so two schedules
picked in a different order are the same cadence.

Weekly with nothing selected is expressible in the form but not on the wire, so
it blocks submit with a message rather than silently saving as Monday.

* fix: keep the weekday pills one line tall and honest about empty sets

Seven 3rem pills wrapped to a second line inside their md cell, spending
height the dialog's no-scroll budget does not have; they now share the
row equally with the locale's narrow weekday labels, composed on the
shared Button so the pills carry its focus ring rather than a bare
feature-styled element. Both labels are built once per locale instead of
fourteen Intl constructions per keystroke, and the cadence summary no
longer describes the Monday fallback while the form says to pick a day.

* fix: name each weekday pill outright on hover

The narrow labels repeat within a week and read by position; until the
week-order preference lands, the position is fixed Sunday-first, so the
title gives a sighted user the full day name without relying on it.
…on Array (LibreChat-AI#15141)

* ⚡ perf: Append Saved Message Ids Instead of Rebuilding the Conversation Array

Every saveConvo read every message id in the conversation (sorted) and
wrote the array back onto the document — twice per chat turn, O(n) in
conversation length, from a write path. The turn's savers know exactly
which message they just wrote, so they now pass it as
metadata.appendMessageIds and saveConvo $addToSet-s it, skipping the
read and the full-array rewrite. Every save without the option — titles,
archive, fork, import, threads — still rebuilds from the database, which
remains the heal point for the drift that message deletion has always
left behind (deletes never ran saveConvo).

The array's consumers read presence or length, or use it as an
optimistic cache placeholder, so incremental maintenance is
behaviorally identical; on traced turns the array stays exactly equal
to the messages collection.

Per-turn queries: 15 -> 13 (two Message.find gone), and the growing
array payload no longer crosses the wire twice per turn.

* 🎯 fix: Brand the Lineage-Only Resolved Conversation Instead of Guessing by Shape

The resolved-conversation files fast path treated an absent files
property as unresolved so the lineage-only partial from a bound
agent-event continuation could not silently hide a conversation's
uploads. But MongoDB never stores an empty files array, so nearly every
real conversation also lacks the property and the fast path never fired
— a follow-up turn on an upload-free conversation still paid the
getConvoFiles round trip.

The synthesized partial is the one object that cannot speak for the
database, so it now carries an explicit symbol brand
(PARTIAL_RESOLVED_CONVERSATION, non-serializing and invisible to key
iteration), and a stored document without files means what it means:
no files. Traced follow-up turns drop from 14 queries to 13.

* 🧪 test: Expect the Appended Message Id in the Route's saveConvo Metadata

messages-get.spec.js pins the exact metadata POST /api/messages passes to
saveConvo; the route now forwards the saved message's _id as
appendMessageIds, which is the behavior the append path depends on.
* feat: clock format and week start preferences

Times were written in whatever convention the browser locale implied, and the
week always started on Sunday. Neither is right for a large part of the user
base: most of Europe reads a 24-hour clock and starts the week on Monday, and a
user running an English interface in a region that does either is currently
given the American convention with no way to change it.

Two General settings, Clock Format (System / 12-hour / 24-hour) and Week Starts
On (System / Sunday / Monday). Their System branch reads the runtime locale
rather than `i18n.language`, which is normalized down to a translation bundle:
`en-GB` and `en-AU` both become `en`, which is exactly the regional part these
two settings depend on, and reading it would report a 12-hour clock and a Sunday
week to a British user.

Week start is typed on the same 0-6 Sunday-first scale the schedule cadence uses
rather than being narrowed to Sunday/Monday, because the System branch reports
whatever the locale says and several (ar-EG, fa-IR) start the week on Saturday.
Engines without `Intl.Locale.prototype.getWeekInfo` fall back to a short list of
Sunday-first regions with Monday, the ISO 8601 default, otherwise: this is a
display default the toggle can always override, so an imperfect fallback degrades
rather than breaking.

Both settings are stored per browser. They describe how this device reads a
clock, which is a property of where someone is sitting rather than of their
account, and a user who moves between a European desktop and a US phone wants
each to read its own way.

Applied to message timestamps, the schedule dialog and card, key expiry and
refill dates, prompt and agent version dates, memory dates, and project chat
lists. The weekday order also drives the schedule dialog's day pills and the way
a weekly cadence reads back, so a wrap-around selection of Sat+Sun+Mon reads
"Monday, Saturday, Sunday" in a Monday-first week instead of "Sunday, Monday,
Saturday".

Dropdown now names its selected value as well as its field label. `aria-labelledby`
REPLACES the trigger's own text, so pointing it only at the caller's label left
the selected value unannounced, which these two settings are the first consumers
to hit.

* fix: teach the week-start fallback the Saturday-first regions

The no-week-data heuristic could only answer Sunday or Monday, folding
ar-EG to Sunday and fa-IR to Monday when CLDR says both start on
Saturday, and the selector offers no explicit Saturday override to
recover with. It now carries CLDR's Saturday-first territories, and the
UAE moves off the Sunday list to the Monday default, where CLDR put it
when its weekend moved to Sat-Sun. The fallback tests delete the
engine's week data for their duration, so they exercise the heuristic
on every engine instead of skipping wherever getWeekInfo exists.

* fix: infer likely regions for bare language tags and stop rebuilding clock formatters

A runtime that reports a language-only locale (bare ar or fa) carried no
region for the week-start heuristic, so those users fell to the Monday
default even though maximize() knows their likely region starts the week
on Saturday. The heuristic now maximizes before defaulting.

The runtime locale and each locale's meridiem answer are also cached at
module scope: every message timestamp mounts useClockFormat, so the
uncached path built a fresh Intl.DateTimeFormat per rendered message,
hundreds in a long conversation, even when the preference ignores the
locale entirely.

* fix: keep the Maldives on Friday in the week-start fallback

CLDR's lone Friday-first territory was in neither fallback set, so
dv-MV (and bare dv, which maximizes to MV) fell to Monday on engines
without week data, with no Friday override in the selector to recover
with. The three per-day sets consolidate into one region-to-day map.

* fix: complete the Sunday-first fallback from CLDR week data

The hand-picked ten Sunday-first regions left the System preference on
Monday for en-IN, id-ID, bn-BD, ur-PK, th-TH and the rest of the long
tail on engines without week data. The list is now every territory whose
und-XX week does not start Monday per CLDR, deprecated codes included,
with a note on how to regenerate it when CLDR moves a territory.

* fix: mock message context across markdown test suites and prevent global plugin cache leak
A schedule's time was three dropdowns side by side: hour, minute, meridiem. That
is three controls for one value, it cannot be read at a glance, and the minute
list was a fixed set of four with the stored value bolted on, so a schedule
already running at :07 could be kept but never chosen.

They become one TimePicker: hour, minute and, where the clock format calls for
one, meridiem, as scrollable columns behind a single trigger showing the selected
time. An hourly cadence gets MinutePicker, the same control with its other
columns dropped, so it reads as the same widget rather than a different one. Both
live in packages/client with their wording passed in as props, so the primitive
carries no translation keys of its own.

Not `<input type="time">`: the browser owns its rendering, and it cannot be
brought in line with the rest of the form.

`hour12` is a required prop rather than a locale-derived guess. The app has
already resolved its Clock format setting, and re-deriving the answer inside the
picker would let it disagree with the summary printed beside it.

The trigger names its selected value as well as its field: `aria-labelledby`
replaces a button's child text, so pointing it at the label alone announced
"Time" and left a screen reader user unable to tell what was selected without
opening the columns and reading them. The columns are a roving-tabindex
radiogroup, arrow keys wrap, and the selected row is scrolled to the middle of
its column on open.

The popover is deliberately not portaled. A Radix dialog sets `pointer-events:
none` on the body while open, so a popover portaled out of it renders correctly
but receives no clicks or wheel events, and its focus trap puts the content out
of tab order too.

Hour and minute are set in one change. Behind separate fields a half-applied edit
could submit a time the user never picked, and the form now carries the hour as
the 0-23 value the cadence stores rather than a 12-hour value plus a meridiem it
has to recombine.
…ibreChat-AI#15142)

* feat: surface event-driven child activity

* fix: keep child task aggregation documentdb-compatible

* fix: address event activity review findings

* test: provide markdown message context defaults

* fix: report bounded child history truncation

* fix: preserve current child activity state

* fix: preserve durable event child activity

* fix: handle missing task timestamps

* fix: keep active event snapshots live

* fix: preserve event activity across valid anchors

* fix: close event child activity gaps

* fix: preserve event activity across resume
* feat: shared empty state for side panels

Bookmarks and Memories each hand-rolled the same empty state: the same bordered
card, the same circular icon surface, the same title and caption sizes, written
out twice. Schedules had none at all, so an account with no schedules got a bare
list with nothing to explain what the panel is for.

One EmptyState primitive in packages/client, taking an icon, an optional title
and description, and an optional action. Bookmarks and Memories move onto it with
no visual change and no copy change. Schedules gets a real empty state, and an
error state with a Retry action, so a panel that failed to load offers a way out
instead of looking empty.

A description with no title takes the title's size rather than the caption's:
where it is the only line, it IS the message.

* fix: drop the create hint for roles without schedule create access

The panel already hides its create button behind hasCreateAccess, but
the empty state still told a USE-only viewer to create a schedule it
offers no way to create. The invitation now renders only when the
capability does.

* fix: suppress the create hint when the quota already blocks creation

A maxPerUser of 0 disables the create button on an empty list, so the
empty state must not say to create one either; the hint now follows the
same effective gate as the button.
…t-AI#15145)

* 🎨 ci: Gate Frontend Jest on Codegraph Selection (Stage 1.5)

* ci: a malformed FILES decision runs FULL, never skips (Codex)

* ci: dev-push runs never cancel each other (Codex P2)

* ci: workflow-file push baseline, cancellable gated jobs, pull-requests read (Codex r4)

* ci: selected paths must live under their workspace, else FULL (Codex r5)

* ci: drop stale selected paths, run FULL when none exist (Codex r6)
…oll (LibreChat-AI#15144)

* ⚡ perf: Use Plain JSON for the In-Memory Cache Store

Every read from the in-memory Keyv fallback paid @keyv/serialize's
Buffer-aware reviver: 0.33ms for a 12KB config-shaped value against
0.038ms for a plain JSON round trip, on every config, role, and model
lookup a request makes. An instrumented sweep of the e2e suite — the
serializer wrapped to flag any value carrying the Buffer marker, armed
in all seven server and fixture processes — found no namespace ever
caching a Buffer.

Plain JSON keeps the semantics readers already rely on: values are
copies, never references into the store, and dates still come back as
ISO strings. A Buffer would now round-trip as its JSON form instead of
reviving; the new spec pins that as the documented contract. The Redis
and file-backed stores are untouched.

* ⚡ perf: Back Off the Trigger Delivery Poll While the Queue Is Idle

The delivery engine issued a claim findOneAndUpdate every second per
replica whether or not any trigger existed — ~86k no-match queries a
day on an idle deployment. The poll now doubles its interval after
each empty claim pass, capped at maxIdleTickMs (default 15s, floored
at tickMs), so an idle replica settles at four queries a minute's
worth of chatter down to one per fifteen seconds.

Nothing that has work waits: enqueues and finished deliveries already
call wake(), which now also snaps the streak and the poll timer back
to the base cadence before claiming. The only latency this can add is
cross-replica pickup of a trigger enqueued elsewhere while this
replica is fully idle — bounded by the cap.

The next timer delay is computed after each claim settles, so the
backoff is never a step behind the queue's state.

* 🎯 fix: Never Let Anything but a Confirmed-Empty Queue Advance the Idle Backoff

Two review findings, both real. A failed claim pass proves nothing about
the queue, yet it advanced the idle streak exactly like a confirmed-empty
one — repeated transient database failures would have stretched recovery
polls toward the ceiling and left due deliveries waiting after recovery.
Failures now reset the streak, restoring the pre-backoff status quo of
one-second retries through an outage and immediate catch-up after it.

And service.requeue(), which revives a dead letter straight in Mongo,
never woke the engine, so a revived delivery could wait out a full idle
interval that the old fixed poll bounded to a second. A successful
requeue now wakes the engine exactly as the enqueue path does; a requeue
that revived nothing wakes nothing.

* 🎯 fix: Never Sleep Past a Known Eligibility Time

A delivery that exists but is not yet eligible reads as an empty queue
to the claim pass, so a retry or defer scheduled a few seconds out could
wait out the full idle interval that the old one-second poll bounded
tightly. The engine computes every one of those future availableAt
times itself — retries, defers, and the ordering recheck — so it now
records the earliest of them and the idle timer never sleeps past it;
the marker clears once reached. The service routes future-dated
enqueues and requeues through the same noteEligibleAt seam and wakes
immediately for due ones, as before.

Deliveries delayed by another replica remain bounded by maxIdleTickMs,
the same class of tradeoff as cross-replica enqueue pickup.

* 🎯 fix: Track Every Eligibility Deadline, Not Just the Earliest

A single next-eligible slot discarded later deadlines: with retries due
at t1 and t2 > t1, reaching t1 cleared the only timestamp and the t2
delivery degraded back to idle-poll pickup, up to maxIdleTickMs late.
The engine now keeps a sorted, deduplicated, bounded list of the future
availableAt times it has seen, prunes entries as they come due, and
re-arms the timer whenever a new earliest arrives — including while the
timer is already sleeping toward the idle cap, which the previous
insert-at-head check missed for an empty list. On overflow the latest
deadline is dropped and that delivery falls back to the capped idle
poll, the same bound that covers deliveries delayed by other replicas.
…t-AI#15147)

The two attribute-flip tests mutated inside act() and then raced a 4 second
waitFor against MutationObserver delivery, so they failed once the client
workspace gained enough suites for a worker to stall past that budget.

Wait on actual observer delivery instead. The hook registers its observer on
mount, so it is ahead of the test's in delivery order and has already reacted
by the time the promise resolves. The new helper filters on data-active-item
because React writes data-active onto the same element when it re-renders, and
an unfiltered observer would resolve on that write instead.

This removes the last wall-clock dependence in the file, so the 20 second
jest timeout is no longer needed.
danny-avila and others added 10 commits September 2, 2026 10:38
* feat: add configurable BYOM permissions

* test: cover BYOM permission settings live

* fix: harden BYOM permission settings

* perf: reuse principals for environment discovery

* chore: normalize BYOM settings style

* fix: Align BYOM Discovery and Admission

* fix: Harden BYOM Permission Boundaries

* fix: Align BYOM Skill Approval Routing

* fix: Preserve Lazy BYOM Approval Capabilities

* perf: Parallelize Cached Environment Reads
Claude Fable 5.1 (`claude-fable-5-1`, GA 2026-09-01) succeeds Claude Fable 5
at the same input/output prices, with a 1M context window and 128K max output.
The Mythos-class helpers added for Fable 5 already match it — `isMythosClassModel`
tests `claude-(?:fable|mythos)[-.]?\d`, so adaptive thinking, summarized-display
opt-in, sampling omission, 1M context, the 128K output clamp, prompt caching,
the Vertex multi-region gate, and the Bedrock PDF exemption all cover 5.1
without a code change. What was missing was registration and pricing.

Cache reads are the one rate that differs, and the substring matcher would have
silently gotten it wrong: `claude-fable-5-1` falls through to the `claude-fable-5`
key, which prices cache hits at the usual 0.1x base input ($1/MTok). Fable 5.1
and Mythos 5.1 price them at 0.025x ($0.25/MTok), so every cache read would have
billed 4x — on exactly the long agentic sessions this model is built for.

- Register `claude-fable-5-1` in the shared Anthropic list and
  `global.anthropic.claude-fable-5-1` in the Bedrock list
- Add 1M context / 128K output entries for `claude-fable-5-1` and
  `claude-mythos-5-1`
- Add $10/$50 rates and `{ write: 12.5, read: 0.25 }` cache rates for both
- Update the `.env.example` and Vertex `librechat.example.yaml` examples
- Test that 5.1 resolves to its own key rather than collapsing onto 5.0, across
  token maps, pricing, cache rates, and Mythos-class detection

The three documented breaking changes from Fable 5 need no handling here:
LibreChat never sends a forced `tool_choice` on the Anthropic path (the agents
SDK omits the param when unset, so the API default `auto` applies); thinking
blocks an earlier model can't read are dropped server-side, unbilled; and
history is rebuilt from stored content parts rather than replayed signed
thinking blocks, so the prefix-binding check has nothing to reject.

Refs LibreChat-AI#15506
* fix: propagate tenant to agent model headers

* fix: propagate tenant across agent model calls
…I#15517)

* 🛑 fix: Forward Run Abort Signal to Foreground Tool Calls

Stopping a generation left in-flight tool calls running on the far side.
For MCP over streamable-http the external server kept burning CPU until
the tool finished or timed out, with no `notifications/cancelled` ever
sent.

The MCP layer was never the problem: `MCPManager.callTool` already
spreads `options.signal` into `client.request`, and the SDK's
`Protocol.request` sends `notifications/cancelled` the moment that signal
aborts. The signal simply never arrived.

Agent tools do not execute inside LangGraph's ToolNode — `eventDrivenMode`
dispatches `on_tool_execute` and `createToolExecuteHandler` invokes them.
The agents SDK puts the run's abort signal on the batch request
(`ToolExecuteBatchRequest.signal`, documented as one handlers should
forward), but the handler destructured every field except `signal` and
built its invoke config without it. `config.signal` was therefore
`undefined` in every foreground tool, so `createMCPTool` derived no
signal and the SDK registered no abort listener.

Forward it. This restores cancellation for all signal-aware foreground
tools, not just MCP. The detached background invoke keeps its own
controller, since that work deliberately outlives the turn.

An aborted call now rejects promptly and resolves an error result rather
than hanging, so the abort is logged at debug instead of as a tool error
— a user pressing Stop should not spray the error log.

* 🔇 fix: Keep Cancelled Tool Calls on the Filtered Path and Out of Error Logs

Addresses both findings from the Codex review of 48fded5.

P1 — the cancellation branch returned before `filteredToolOutputResult`,
so with tool-output filtering configured an aborted call's error text
reached the turn uninspected. Worse, `runSignal.aborted` says the run is
over, not that this rejection was the cancellation: an unrelated failure
racing the Stop took the same unfiltered exit. Cancellation now only
selects the log level; filtering and the result shape are unchanged.

P2 — `createMCPTool` logs its own error before wrapping and rethrowing,
so every Stop during an MCP call still emitted an error-level MCP entry
and could pollute operational alerts. That catch now recognizes the abort
and logs at debug; the wrapped message still reaches the turn.

Both are covered by tests that fail against the previous commit.

* 🎯 fix: Require an Abort-Shaped Error and Spare Shared OAuth Flows

Addresses both findings from the Codex review of b3c6708.

An aborted run signal proves the turn is over, not that the rejection in
hand was the cancellation — a permission, OAuth, or upstream failure can
reject in the same tick a user presses Stop. Both quiet-log branches now
require the error to look like an abort as well, so a real failure racing
the Stop stays at error level and visible to operational alerts.
`isAbortError` moves to `@librechat/api` and the copy in
`abortMiddleware` is retired, keeping one implementation. Its spec pulls
the real function through its partial package mock rather than asserting
a stub.

Forwarding the run signal also reached ActionService, where two of the
three OAuth flows are keyed `userId:action_id` and so outlive the run
that opened them: a second run for the same action joins the very same
record, and the browser's OAuth callback reads its metadata to exchange
the code. `monitorFlow` deletes the key when a waiter's signal aborts, so
one Stop could strand a concurrent run and discard an authorization the
user had already granted. Those two flows no longer take the signal; the
run-scoped login flow, keyed by thread and run, still does.

Every guard is covered by a test that fails when the guard is removed.

* 🧷 fix: Detach the Stopped OAuth Waiter Instead of Dropping Its Signal

Addresses the Codex review of d53ff17.

Withholding the run signal from the shared Action OAuth flows protected
concurrent runs and the browser callback, but left this invocation
attached to the flow after the Stop. A callback landing late then resumed
`_call` straight into `preparedExecutor.execute` — a consequential API
request running after the user had already stopped the turn. Handing the
signal back is not the answer either, since `monitorFlow` deletes the
shared `userId:action_id` key on abort.

`detachOnAbort` separates the two: the caller stops waiting the moment
the signal aborts, while the shared flow runs on for whoever else needs
it — another run, or the callback exchanging its code. Late settlement of
the detached work is swallowed rather than surfacing as an unhandled
rejection.

Both shared flows now use it. The run-scoped login flow keeps the signal
directly, as nothing outside its run observes it.

* 🪪 fix: Keep an Action OAuth Abort an Abort

Addresses two of the three findings from the Codex review of 70fc078.

Detaching the stopped waiter left the rejection to be misread on the way
out. The refresh catch treated it as a failed refresh and called
`requestLogin()`, emitting an OAuth prompt and opening pending
authorization state for a turn that had already ended. `requestLogin` and
the surrounding auth catch relabelled it `Failed to authenticate OAuth
tool` / `Authentication failed`, and the outer catch handed that to
`logAxiosError` — so an ordinary Stop produced two error-level entries
and returned failure text as the tool's result. Each boundary now lets an
abort through unchanged, and the outer catch logs it at debug and
rethrows so it stays a cancellation end to end.

Deferred: waiter-only cancellation in `FlowStateManager`, so a detached
`monitorFlow` stops polling instead of running out the flow's TTL. That
poller is bounded at three minutes and lives exactly as long as it does
for a waiter that never left, so it changes no lifetime this branch
introduced; giving `FlowStateManager` a non-destructive abort mode is its
own change, shared with MCP and indexSync.

* 🤫 fix: Classify the Abort at the MCPManager Boundary Too

Addresses the Codex review of 9bcf1aa.

`MCPManager.callTool` logs every rejection at error level before
rethrowing to `createMCPTool`, so a user Stop still emitted an MCP error
from the inner boundary even after the outer one learned to recognize it.
The same signal-plus-abort-shape classification now applies here.

The review also noted, correctly, that the existing test could not have
caught this: it replaces `getMCPManager` wholesale, so it only ever
exercised the outer logger. The new cases drive the real manager with a
connection whose `client.request` rejects the way the SDK does once a
request signal aborts, and cover both the cancellation and a genuine
failure racing the Stop.

* 🧹 chore: Restore Import Order in the Agents Tool Handler
…AI#15491)

* ⏭️ feat: Honor an Interrupt Before the Model Has Answered

An interrupt armed while the model is still thinking sat as "Interrupting"
until the entire turn finished, then landed as a terminal continuation. The
cause is upstream: the SDK reads `shouldPreempt` once per streamed chunk, and
its seal gate requires non-empty text — so a silent provider is never polled at
all, and a reasoning-only turn is never sealable.

`@librechat/agents` gains a discard-and-restart path for exactly that window
(`HOOK_PREEMPT_RESTART_CAPABLE`). This wires the host half:

- `GenerationJobManager.subscribePreempt` registers run-scoped wake listeners,
  notified after an ACCEPTED arm — local or cross-replica, since both land in
  `armPreemptIds`. Fenced on `createdAt` like every other preempt entry point,
  and a throwing listener never fails the arm a queued steer depends on.
- `createSteerPreemptPoll` hands that channel to the SDK as
  `StreamPreemption.subscribe`, gated on a new `isSteerPreemptRestartSupported`
  probe so an SDK that cannot act on a wake is never handed one.

The wake is a hint only: `isPreemptRequested` stays the single authority, so
the level-triggered contract the seal path depends on is unchanged.

Inert until `@librechat/agents` publishes the restart contract — the probe
returns false against the pinned 3.7.11 and the run keeps today's behavior.

* 🩹 fix: Replay an Already-Armed Preempt on Subscribe

Preempt requests are level-triggered, and the window between a job becoming
steerable and the SDK installing its model-attempt listener is real. An
interrupt landing there was recorded with no callbacks to notify, and on a
silent or reasoning-only turn no later chunk poll may ever run — leaving the
request to wait out the whole turn, the exact stall this channel removes.

`subscribePreempt` now replays an existing arm to the newly registered listener
only, after registration: waking the whole set would re-notify runs that
already looked, and waking before registration would leave a concurrent arm
with nowhere to land. The `createdAt` fence is unchanged and still gates the
replay.

The SDK also reads the level flag once at each attempt's start, so this is
belt-and-braces rather than the sole cover — but it makes the host correct on
its own terms instead of resting on when the SDK happens to look.

* 🔧 chore: Update @librechat/agents dependency to version 3.7.14 in package-lock.json and related package.json files

* 🔧 chore: Update dependencies in package-lock.json and package.json

- Bump versions for @humanfs/core (0.19.1 to 0.19.2), @humanfs/node (0.16.6 to 0.16.8), @xmldom/xmldom (0.8.13 to 0.8.15), and qs (6.15.2 to 6.16.0).
- Add @humanfs/types (0.15.0) as a new dependency for @humanfs/node.
- Update dependencies for side-channel and side-channel-list to their latest versions.
* ⚡ feat: Add Gemini 3.8 Flash Support

Adds first-class support for Google's Gemini 3.8 Flash (`gemini-3.8-flash`,
GA 2026-09-02) for both the Gemini API (AI Studio) and Google Cloud Gemini
Enterprise Agent Platform, following the Gemini 3.7 Flash integration (LibreChat-AI#14818).

The Flash-family handler that PR generalized already carries the shape 3.8
needs, so registering the model is a one-line rule rather than new behavior:
it inherits the strip of deprecated sampling params (temperature/topP/topK),
the rejected penalty params, and `thinkingBudget`, defaults to `medium`
thinking, and — like 3.7 — errors on `minimal`, so an explicit `minimal` is
substituted with the nearest supported level, `low`.

- Context window (1,048,576) in googleModels; API + cache pricing in tx.ts
- Model dropdown (config.ts) and GOOGLE_MODELS examples for both integrations
- Register the model in the Flash-family thinking-rule table
- Apply the same introductory pricing as 3.6/3.7 Flash ($0.75 in / $3.75 out /
  $0.075 cached, per 1M), reverting to $1.50 / $7.50 / $0.15 on 2027-01-01;
  the existing comments at both call sites now name 3.8 alongside 3.6/3.7
- Tests mirroring the 3.7 Flash coverage: thinking default, legacy-param strip,
  explicit level pass-through, `minimal` substitution, versioned aliases,
  context window, model-key mapping, and rates

Refs LibreChat-AI#15516

Ref: https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash
Ref: https://ai.google.dev/gemini-api/docs/pricing

* 🔧 chore: Update @librechat/agents dependency to version 3.7.15 in package-lock.json and related package.json files
…15530)

* 🫙 fix: Drop Blank Content Blocks From Promptless Sends

* fix: Preserve the promptless turn instead of dropping it
…5505)

* 🧊 feat: Persist Context Fading Tier Across Agent Runs

Carry the pruner's latched context-fading tier from `@librechat/agents`
through `contextMeta` the way `calibrationRatio` already travels, so a
historical tool result keeps the same truncated bytes from one run to the
next and Anthropic's prefix-based prompt cache survives across turns
(LibreChat-AI/agents#497).

- Add `IAgentFadingTier` to the message and conversation `contextMeta`
  types, schemas and the data-provider zod schema
- Add `isAgentFadingTier`, `resolvePersistableFadingTier` and
  `resolveRunContextMeta` in `packages/api`; a tier is persisted once it
  carries information (masking active or a budget below the window), and
  a latched tier is kept even at a neutral calibration ratio
- Seed `createRun({ fadingTier })` from the parent response's contextMeta
  regardless of encoding, since caps are character-based; the field rides
  the existing forward-compatible `runConfig` variable and older SDK
  versions ignore it, as does `Run.getFadingTier` being absent
- Accept a valid `fading` field in event actor context and reject a
  malformed one, matching the calibration checks
- Capture run context meta through one module-level helper for both
  completion paths instead of two inline copies

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Seed Fading Tier Across HITL Resume, Validate at the DB Layer

Address review findings on fading tier persistence:

- Leave the "is this tier worth persisting" decision to the SDK, whose
  `Run.getFadingTier()` now returns only informative tiers; comparing
  against the client's window misclassified untouched conversations
  whenever a reserve ratio was configured
- Carry `contextMeta` through the HITL pause projection (staged approval,
  job metadata, resume state) and seed the rebuilt client from it, so a
  paused turn resumes with the same tier and calibration instead of
  re-deriving a shallower one and rewriting the prefix
- Share run seeding between `chatCompletion` and `resumeCompletion`, and
  make capture tolerate partial runs and bare resume contexts
- Validate `contextMeta.fading` in `commitAgentEventActorState` with a
  shared `isAgentFadingTier` guard in data-schemas, so a malformed tier is
  rejected at the DB layer instead of silently cold-starting the actor
- Reuse `IAgentEventActorContextMeta` for `IMessage.contextMeta` instead of
  extending an inline duplicate

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 test: Cover Resume Seeding From Paused Context Meta

Assert the resume controller seeds the rebuilt client from the context meta
captured at the pause before rebuilding the run, and move the capture
helper's JSDoc back above its function.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧹 style: Sort Imports in Fading Tier Plumbing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Persist Fading Tier Through Redis Jobs and Branches

Address Codex and Copilot review findings on fading tier persistence:

- Deserialize `contextMeta` in `RedisJobStore`, whose explicit read mapper
  rebuilt every other pause field but this one, so Redis-backed
  deployments resumed with no tier to seed after every HITL pause
- Copy server-private `contextMeta` onto the assistant message created by
  `POST /api/messages/branch`, so the next turn seeds its pruner the same
  way it would from the parallel source response
- Constrain the `fading` subdocuments in both Mongoose schemas (version
  enum, positive budget, required fields) to match the zod schema
- Throw a distinct "fading tier is invalid" error from both context meta
  validators instead of reporting a calibration failure

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Carry Context Meta Through Stop and Re-Pause

Addresses the second Codex pass on the fading-tier persistence:

- A re-pause whose resumed segment has nothing persistable now clears the
  job's `contextMeta` in the same transition instead of leaving the first
  pause's calibration and tier for the next resume to seed from.
- The client publishes the run's live calibration and fading tier onto the
  job after each pre-invoke context snapshot (deduplicated on value), and
  the Stop path copies `jobData.contextMeta` onto the stopped response, so
  a follow-up from a stopped turn keeps the prompt prefix stable.
- Mid-run captures (HITL pause, Stop) read the graph's live state; `Run`
  refreshes its own getters only after the stream settles, so the pause
  used to persist the seeds it started from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Fence Context Meta Publication Ahead of Stop

A Stop handled by another replica reads the job before the owner is
signaled, so a fire-and-forget publish could lose the race, and a run
stopped before its first context snapshot never published the tier it
inherited. The client now publishes the inherited seed before the run
streams or resumes, and the context-usage handler awaits each
snapshot's publish so the write lands before the model call it
describes begins. Failures still only log; the pre-run publish is
optional-called so bare resume contexts keep working.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Read Back Context Meta After the Abort Claim

The run owner publishes its context meta ahead of each model call and
awaits the write, so a publish landing between the abort's initial job
read and its terminal claim describes the call whose partial output the
abort snapshot carries. abortJob now reads the same-epoch job back after
the claim and content re-read and takes its context meta, so the stopped
response persists the tier that produced its bytes. The refresh is
best-effort and logs on failure like the content refresh beside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Share In-Flight Context Meta Writes and Clear Neutral Live State

Two snapshot callbacks in a parallel-agent run can capture the same tier
before the first write settles; the publisher now caches the in-flight
promise per serialized value so the second caller awaits the same
durable write instead of treating an uncommitted value as published.
A live snapshot with nothing left to persist after an earlier publish now
writes a neutral record (ratio 1, no tier), since a running job's fields
cannot be deleted through the metadata writer, so a Stop no longer
persists an earlier non-neutral state onto the response. The pre-run
seed publish is unchanged and is told apart from live snapshots by a
flag from the context-usage sink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 chore: Drop Unused Rest Parameter in Context Meta Test

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 feat: Persist Per-Agent Fading Tiers as Compact Context Meta

Reconciles the host with @librechat/agents at 9aacad1a, where graph
history stays canonical and each Run derives a provider-only projection
from the latched tiers. LibreChat therefore persists only compact state:
the calibration ratio, the default agent's tier, and now the per-agent
tiers from Run.getFadingTiers(), stored as validated entries so agent IDs
never become MongoDB field names and restored onto a null-prototype
record for RunConfig.fadingTiers. Truncated messages, canonical tool
content, projection provenance and per-message truncation state are
never persisted; the SDK's latched flag is stripped on capture.

The per-agent map travels everywhere the single tier already did:
response contextMeta, event-actor checkpoint state, HITL pause and
resume job metadata in both stores, the Stop path, and branch creation.
A shared Mongoose definition keeps the message and conversation schemas
identical, the zod schema validates entries, and commitAgentEventActorState
rejects malformed maps. Comments no longer describe graph messages as
truncated; only the provider projection is.

Tests cover: tool results and inputs reach the persisted content parts
at full size; contextMeta carries exactly the compact tier and
calibration fields; a persisted snapshot round-trips into the seeded
RunConfig unchanged and prototype-safe; per-agent tiers survive pause,
Redis job round-trips, and event-actor context validation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Restore Event Actor Types and Publish Seeds Before Abortable Setup

The previous commit's doc rewording in the conversation types dropped the
event-actor interfaces that precede the fading tier, which broke the
declaration bundle in CI; they are restored unchanged.

The inherited context meta is now published onto the job as soon as the
parent's state is loaded and awaited at the top of chatCompletion and
resumeCompletion, ahead of run creation and the other abortable setup
stages, so a Stop during setup still persists the parent's tiers onto the
stopped response. The partial response saved when every subscriber
disconnects now carries the job's context meta like the Stop and pause
paths do. The branch route keeps the source's context meta in the saved
message but strips it from the HTTP response, matching client reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Serialize Distinct Context Meta Publications

Overlapping snapshots from parallel agents could carry different tiers;
each started its own write and both job stores keep whichever write
finished last, so an older snapshot could overwrite a newer one. Each
distinct publication is now issued after the previous one settles, in
call order, so the newest snapshot is what a Stop reads back. Equal
values still share one write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Project Root Message Reads and Keep Inherited Meta on Setup Failure

The root GET /api/messages paths returned raw documents: the
single-message query read called getMessages without a projection and the
cursor page performed an unprojected find. Both now apply
CLIENT_MESSAGE_SELECT, with getMessagesByCursor accepting a select option,
so the persisted fading tiers never reach the client through the
paginated endpoint; the search-hydration read projects the same way.

A turn that loads a parent's context meta but fails before its run exists
used to replace the inherited meta with the capture of a missing run, so
the persisted error response lost the last valid tiers. Both finalizers
now keep the inherited meta when no run was created while a created run's
neutral state may still clear it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 refactor: Move Context Meta Publication Into packages/api

The publication coordinator (ordering, deduplication, in-flight sharing,
failure state) now lives in packages/api as createContextMetaPublisher,
with selectRunContextMetaToPublish deciding what a publication carries;
the legacy client keeps only the wiring to the job metadata writer. A
transiently failing write is retried with a short backoff before the
publication is reported and forgotten, so a snapshot's model call no
longer proceeds on the first rejection while the job holds the previous
record. The SDK swallows handler errors, so retrying is the only way to
strengthen the fence without failing the turn.

A terminal save now supplies contextMeta: null when the finished run has
nothing to carry, and saveMessage unsets a previously stored value in
that case, so a disconnect snapshot's record cannot outlive the run that
completed neutrally.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Widen the saveMessage Method Type for the Null Unset

The exported method interface still typed contextMeta as the message
field, so the spec exercising the null unset failed typechecking; it now
mirrors the implementation signature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Publish Every Agent's Snapshot, Sanitize Search Hits, Unbound Agent IDs

The context-usage handler now awaits the run's context meta publish for
every ON_CONTEXT_USAGE event, hidden sequential agents included, while
the visible-snapshot bookkeeping and the client emission keep their gate;
a Stop before the next visible snapshot finds the tier a hidden agent's
model call latched. Search results are built from the hydrated hit minus
its server-private contextMeta, since Meilisearch hydration projects every
schema field. The per-agent tier entry guard no longer caps the agent ID
length: ephemeral agent IDs encode endpoint, model and sender without a
bound, and dropping such an entry would let that agent alone re-derive
its tier on the next turn. Also formats the publication spec so the
static checks pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Unset Context Meta Atomically, Keep Publication State After a Failed Write

- `saveMessage` folds the `contextMeta` unset into the same update that
  persists the terminal response, on both the plain and the provenance-merge
  paths, so a failure between two writes can no longer leave a completed row
  carrying a disconnect snapshot's state.
- `createContextMetaPublisher` tracks whether any record has committed
  separately from the retryable latest publication, so `hasPublished` stays
  true after a later publication exhausts its retries and a following neutral
  snapshot still overwrites the earlier record on the job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Carry Context Meta Through the Resumable Stop Route and Neutral Resumed Completions

- The resumable Stop route copies the refreshed `jobData.contextMeta` onto the
  terminal assistant message it persists, as the abort middleware already
  does, so a stopped resumable response keeps the tiers that produced its
  partial output.
- A resumed run that completes with neutral context state now saves
  `contextMeta: null`, which unsets the paused segment's record instead of
  letting an omitted field keep it on the completed row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Trust Only Server-Authored Context Meta, Unset It on a Neutral Stop, Read It From the Job on Disconnect

- `POST /api/messages/:conversationId` and conversation imports drop any
  client-supplied `contextMeta`, and the run seeds its calibration and fading
  tiers only from a server-authored parent response, so a forged tier can never
  shape a provider projection.
- Both Stop paths save `contextMeta: jobData.contextMeta ?? null`, so a job that
  re-paused with neutral state unsets what an earlier pause stored on the row
  instead of leaving it behind.
- The disconnect partial save reads the same-epoch job record for the run's
  published context meta, since the client-facing resume snapshot never carries
  server-private state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧪 test: Mock the Job Store in the Resume Metadata Spec

The disconnect partial save now reads the same-epoch job record through
`GenerationJobManager.getJobStore()`, so the resume metadata spec's manager
mock provides it as the other disconnect spec already does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 🧊 fix: Keep Context Meta Off the POST Message Response

`POST /api/messages/:conversationId` returned the saved row verbatim, so a
client write against an existing server-authored message echoed the row's
stored context meta. The route now projects the row through the same client
projection the branch route uses, which drops the server-private field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

* 📦 chore: Bump @librechat/agents to 3.7.16 for Persisted Fading Tiers

3.7.16 is the first release that carries LibreChat-AI/agents#497, so
`Run.getFadingTier()` / `getFadingTiers()` now report the latched tier
this branch persists and `RunConfig.fadingTier` / `fadingTiers` seed the
next run from it. The dependency set is unchanged from 3.7.15; only the
package entry moves.

Adds an SDK-backed round trip to the fading spec: a first pruner latches
an informative tier, `resolveRunContextMeta` reduces it to the persisted
shape, and a second pruner seeded from that shape reproduces the first
run's projected tool-exchange bytes under instruction and calibration
drift, while an unseeded pruner under the same drift relaxes its tier and
rewrites them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF

---------

Co-authored-by: Claude <noreply@anthropic.com>
* chore: prepare v0.8.8-rc2 release

* test: satisfy mongo meili static checks

* test: avoid unsafe logger spy assertion

* chore: refresh v0.8.8-rc2 release

* test: restore mongo meili test isolation

* chore: normalize v0.8.8-rc2 versions

* docs: refresh v0.8.8-rc2 release surfaces
@coderabbitai

coderabbitai Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@busla
busla marked this pull request as draft September 9, 2026 09:48
sindriii and others added 10 commits September 9, 2026 10:09
…the Fetched Catalog

New `models.filter` option for custom endpoints: serve `default` ∩
fetched instead of replacing `default` with everything the API returns,
so several endpoints over one gateway can each offer their own slice of
its catalog, in declared order. A failed or empty fetch falls back to the
declared list, as it always has without `filter`.

A filter-managed endpoint left with an empty model list renders as an
empty picker entry and an unusable Agent Builder provider, so the
endpoints route withholds it. User-provided endpoints are kept — their
empty list reflects the user's own key — and the route resolves models
only when some endpoint filters, failing open when it cannot.

An empty filter-managed endpoint is unavailable, not being asked for an
illegal model: stored conversations and agents naming it would otherwise
earn their owners violations for a catalog change they had no part in.
Both the chat and agents validation paths reject without logging one.

One request resolves the models config from several places (model
validation, token config, agent initialization), and each resolution may
re-fetch gateway catalogs, so the resolved config is memoized per request
in a WeakMap, evicting on failure so a later caller retries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add `modelLabels`, an endpoint-level record mapping model ids to display
labels. Purely presentational: the id stays what is declared, fetched,
selected, stored on the conversation and sent upstream, and a model with
no entry renders its id.

One helper, getModelName, resolves the agent name, the assistant name,
or the declared label; the model list, search results, the selector's
closed trigger, the selection announcement, the @-mention menu, the
favourites list, the model parameter dropdowns, the endpoint settings
panels, the added-conversation header and the Agent Builder read it or
the declared map directly through getModelLabel. A declared label is
additive in search via modelSearchNames — labelling a model never makes
its id unsearchable — which also consolidates three inline copies of the
name-resolution rule and fixes the globe icon for unnamed public agents
in search results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds an optional per-MCP-server `deferLoading: boolean` config flag.
When true, every tool from that server defaults to deferred loading:
the model receives the `tool_search` tool plus a name-only listing
instead of each tool's full JSON schema, saving context on large
tool sets. Unlike the per-agent `tool_options[toolId].defer_loading`
toggle, it also applies to ephemeral agents (model + attached MCP)
that carry no `tool_options`.

Extends the existing `deferred_tools` capability (LibreChat-AI#11295) to the
server-config level and the ephemeral path. Complementary to the
proposed MCP toolFilter (LibreChat-AI#13346 / LibreChat-AI#11088).
…ntMessages crash (#64)

The streaming content aggregator builds message content by index and yields a
sparse array; an interrupted/partial save persists a hole that serializes to
null in MongoDB. On replay, @librechat/agents formatAgentMessages reads
part.type with no null guard and crashes.

Sanitize content holes at getMessages, the single DB read chokepoint for
conversation history, so both already-corrupted and future rows are
neutralized for every consumer (formatAgentMessages, token counting, edit path).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@busla
busla force-pushed the sync/v0.8.8-rc2 branch 2 times, most recently from 8c2ea5c to bca44bf Compare September 9, 2026 10:22

busla commented Sep 24, 2026

Copy link
Copy Markdown
Author

Superseded by #81 (fork stack rebuilt on upstream v0.8.8-rc4). Branch sync/v0.8.8-rc2 is kept because #80 targets it.

@busla busla closed this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.