Skip to content

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

Merged
pull[bot] merged 2312 commits into
innFactory:devfrom
LibreChat-AI:main
Sep 21, 2026
Merged

pull[bot] merged 2312 commits into
innFactory:devfrom
LibreChat-AI:main

Conversation

@pull

@pull pull Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

See Commits and Changes for more details.


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

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

aeyeopsdev and others added 30 commits September 6, 2026 17:26
Co-authored-by: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com>
)

* feat: expose skill discovery and authoring through management API

* fix: declare public skill management schema and handler types

* fix: preserve skill permissions and shared management error semantics

* fix: validate skill file paths before writing storage

* test: isolate execution limiter routes from skills management

* fix: align skills discovery access and file upload protections

* fix: authorize skills operations before sync and upload admission

* perf: overlap independent skills authorization reads

* style: sort skills management imports
* test: Add Native BYOM Acceptance Harness

* test: Keep Acceptance Startup and Mongo Environment Isolated

* test: Drain In-Flight BYOM Acceptance Resources on Shutdown

* test: Tolerate Transient Process Probe Denial During Teardown
* 🪪 feat: Configurable Langfuse Trace User Identity and Metadata

* 🪪 fix: Prefer createRun's conversationId and warn once on an unset userIdField

* 🪪 fix: Validate Langfuse trace field names at the point of use

* 🧹 chore: Sort identity imports

* 🪪 fix: Source the trace model label from the initializer, not a prompt-affecting option

* 🧪 fix: Build the trace context without a prototype so partial client contexts resume

* 📦 chore: Bump `@librechat/agents` to v3.8.0 for the Langfuse trace userId override
…15669)

* fix: dismiss menus when composer focus changes

* 🎯 fix: Keep Composer Focus Off Clicked Controls So Menus Can Close

Ariakit records document.activeElement at open time as a menu's disclosure.
The composer surface focused the textarea on every bubbled click, including
the click that opened the Tools or attach menu, so the textarea became the
disclosure and the menu ignored every later textarea interaction. The Tools
menu went from modal to non-modal in #14979 (v0.8.8-rc2), which removed the
backdrop that had been closing it anyway.

Hoists the interactive-target selector, adds label to it, documents the
mechanism at the guard, and gives the composer surface a stable test id so
the empty-space focus test no longer depends on a utility class. Adds a test
that opens a menu and proves a textarea click closes it.

Closes #15624

* 🎯 fix: Restore Textarea Focus After Send, Steer and Stop Controls

The interactive-target guard also skipped the bubbled click that used to
return focus to the textarea after a mouse click on send. The send button
is then disabled or swapped for the stop control, leaving focus on body.
Route that refocus through a shared helper called from the form submit,
the during-run consume callbacks, and the stop button, keeping the
touchscreen exception. Adds a test that a mouse click on send leaves the
textarea focused; it fails without the submit refocus.

* 🎯 refactor: Exempt Only Focus-Owning Targets From the Composer Refocus

The blanket 'button' exemption inverted the surface's long-standing
behavior for every control, so each control that relied on the bubbled
refocus (send, stop, steer, badge toggles) became its own regression.
State the rule the other way round: the surface refocuses the textarea
after any click except on a target that owns focus itself (links, form
fields, labels) or opens or belongs to a popup (aria-haspopup disclosures
and menu/listbox/dialog content, which React bubbles through portals).
Matches that contain the surface itself are ignored so a host dialog can
never disable the refocus. Drops the explicit refocus calls, which plain
buttons no longer need.

* 🎯 fix: Restore Textarea Focus From Popup Actions That Consume the Composer

The during-run alternate actions live in an Ariakit hovercard, which is
portaled dialog content and therefore exempt from the surface's bubbled
refocus. Choosing Steer or Queue there consumed the text and unmounted
both the button and the hovercard, leaving focus on body. Actions that
consume the composer from inside a popup now restore focus themselves
through a shared consume callback. Adds a ChatForm test that opens the
real hovercard with screen-coordinate mouse travel, chooses Queue, and
asserts the textarea is focused; it fails without the refocus.

* 🧪 test: Expect Escape to Return Focus to the Quote Pill

The quotes e2e asserted that Escape on the selections popover focused
the textarea. That held only through the bug this branch fixes: Enter on
the pill fired a click that bubbled to the composer surface, the textarea
took focus mid-open and was recorded as the popover's disclosure, and
Ariakit then 'restored' focus to it on hide. With the surface no longer
stealing focus from a popup disclosure, the pill is the disclosure and
Escape returns focus to it, as PendingQuoteChips documents. The guard
against focus landing on body is unchanged.

* 🎯 fix: Restore Focus When Removing a Quote From the Selections Popup

The remove buttons in the selections popup are popup content, so the
surface no longer refocuses the textarea for them, and the clicked
button unmounts with its row. Removing the second-to-last quote also
unmounts the popup and its pill, so Ariakit has nothing to restore focus
to and it fell to body. The chip now restores focus itself: to the
textarea when the popup collapses, otherwise to the popup so keyboard
users stay inside it. Adds tests for both, plus one proving the primary
during-run submit still refocuses through the surface (the hovercard
anchor carries no popup attributes, so it bubbles like any button).

* ♿ fix: Keep Quote Removal Focus Guarded and on a Visible Control

Route the chip's collapse refocus through the composer's guarded helper
so a tap on a touchscreen does not raise the keyboard, and after removing
one of several quotes focus the remove button now at the same row (or
the last one) once React has re-rendered the list, instead of the
outline-less popup container. Tests pin both; each fails without its fix.

* test: make quote popup focus checks deterministic

---------

Co-authored-by: Jackson Riding <99007683+jacksonriding@users.noreply.github.com>
* fix: surface Bedrock content-filtered responses

* refactor: extract model refusal metadata

* test: preserve refusal helper in agent callback mock

---------

Co-authored-by: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
* 🔒 fix: Assert the Tenant When Saving a Persisted Document

`doc.save()` on an already-persisted document issues `update` filtered on
`_id` alone. The tenant-isolation plugin stamps `tenantId` onto the payload
but never asserts it in the predicate, so the write is scoped only by
provenance — safe in the normal flow, since you can only fetch a document
your tenant can see, but unguarded when the `_id` comes from an unscoped
source: `runAsSystem`, a cached id, or a client-supplied id.

Found by the driver probe added in the previous commit, which is also what
proves the fix: its characterization test flips from asserting the predicate
is absent to asserting it is present.

`$where` is Mongoose's documented public hook for this — "additional
properties to attach to the query when calling `save()` and `isNew` is
false" — and its own sharding plugin uses it to attach a shard key the same
way. It is absent from the TypeScript types, hence the cast.

## Why this cannot break existing writes

- `isNew` documents return early; all three `transaction.ts` saves and the
  `pluginAuth.ts` save construct their document, so they are untouched.
- A document that never carried a `tenantId` yields no predicate, so
  pre-tenancy rows and the global `Role` collection still save.
- With no tenant context — every single-tenant deployment — the scope is
  unscoped and no predicate is ever added.
- The remaining two call sites fetch and save inside the same request
  context, so the carried tenant matches by construction. `session.ts`
  reaches `save()` via `createSession`, which passes an `isNew` document.
- A mismatch fails loudly with `DocumentNotFoundError` rather than silently
  no-op'ing, and optimistic concurrency, array writes and subdocument writes
  were each verified to still work alongside it.

80 suites / 2793 tests green.

* 🧭 fix: Reset Tenant Save Predicates Across Scopes

* 🛡️ fix: Close Persisted Tenant Save Bypasses
Co-authored-by: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com>
* feat: harden early buffer recovery observability

* fix: fence recovery to emitted frontier

* fix: close recovery observability races

* fix: require atomic recovery capabilities

* fix: fail incomplete recovery attachments closed

* fix: preserve legacy store contract

* fix: correlate failed overflow persistence

* fix: break recovery type dependency cycle

* fix: reconcile distributed recovery lifecycle

* fix: preserve content snapshot contract

* fix: harden cross-replica recovery lifecycle

* fix: lease recovery subscriber state

* fix: close recovery lifecycle edge cases

* fix: close recovery validation races

* test: align subscriber lease cleanup

* fix: harden subscriber lease lifecycle

* fix: finalize subscriber lease handoffs

* fix: reconcile recovery error replacements

* fix: preserve durable recovery frontier

* test: expect durable frontier gaps

* fix: scope durable snapshot protocol

* fix: trust durable terminal recovery payloads

* fix: close overflow recovery rollout races

* test: bound overflow race fixture memory

* fix: bound overflow marker refresh

* test: reduce recovery fixture memory

* fix: fence overflow recovery admission

* fix: close overflow recovery admission races

* fix: scope overflow admission fencing

* fix: preserve owner replay after remote admission

* test: await pre-admission append fencing

* test: reflect pre-admission durability
* 🛡️ fix: Harden Conversation Imports and Upload Handling

* 🩹 fix: Address Import Hardening Review Findings

* 🩹 fix: Map Clone Size Validation Errors

* 🩹 fix: Chunk Import Cleanup Queries
* feat: Define Conversation Code Approval Constraints

* style: Sort Code Approval Test Imports

* fix: Enforce Explicit Code Environment Policy
`@lhci/cli@0.15.1` pins `lighthouse` to an exact `12.6.1`, so the Lighthouse
lane could never move off a release whose `puppeteer-core` still depends on
`extract-zip` — an advisory with no patched version. That pin, not Lighthouse
itself, was holding 8 of the repository's 16 `npm audit` findings open.

LHCI was only ever a subprocess wrapper here: `audit.ts` shelled out to
`@lhci/cli/src/cli.js` for `collect` and `assert`, and LHCI's own node runner
shells out to `lighthouse/cli/index.js` in turn. Call that CLI directly, run
the three navigations in a loop, and assert the median budgets in TypeScript
next to the assertions `load.spec.ts` already makes.

- `npm audit`: 16 findings (7 high, 1 moderate, 8 low) -> 6 low. The remainder
  is the pre-existing `elliptic` chain under `vite-plugin-node-polyfills`,
  which has no fixed version and is unrelated to this lane.
- Lighthouse 13 removed `largest-contentful-paint-element` and moved the LCP
  node into `lcp-breakdown-insight`. Reading it through a `lcpElement` helper
  keeps the "the transcript must be the LCP element" guard working and makes
  the next rename fail loudly in one place.
- Reports move to `.lighthouse/` as `lhr-N.report.{json,html}`; cookie
  redaction, the API timing table and the desktop/provided-throttling settings
  are unchanged. Budgets are now printed as a table immediately before they are
  asserted, so the workflow's 80-line failure comment always contains them.
- `CHROME_PATH` and `LIGHTHOUSE_CHROME_FLAGS` are documented: chrome-launcher
  prefers the Windows Chrome under WSL, whose debugging port Linux cannot
  reach.
* 🎯 fix: Land a Steer Applied Before the First Run Step on the Live Placeholder

An interrupt (steer + preempt) sent before the model has produced a token
is applied server-side at content index 0 and stamped with the response
id the server pre-allocated at job creation. The pane, however, still
renders the in-flight response under the `${userMessageId}_` placeholder
until the FIRST run step renames it, so the exact-id lookup missed, the
bounded next-frame retry ran out silently, and the interrupt vanished
from the live view until a refresh — while the spinner kept going.

`findResponseMessageIndex` now accepts the pane's OWN placeholder
identities as an explicit fallback (never a positional guess, per the
regenerate rule) and both the steer and activity-label resolvers
delegate to it. `useResumableSSE` passes the submission's placeholder id
and the padded user-message id, read at call time so the `created`
reassignment is honored. The rename copies placeholder content forward,
so the part rides into the renamed row.

* 🧭 fix: Carry an Early Steer Through the Rename for Regenerates and Edited Resubmissions

A regenerate seeds the renamed response from `submission.initialResponse`
rather than the store tail, and an edited resubmission seeds from that
object's content, so a steer landed on the placeholder only in the store
was dropped by the first run step's rename. `syncSubmissionPlaceholder`
keeps the submission the step handler receives in step with the
placeholder this pane mutated.

The steer also claims the server-local index space that run steps and
labels already shift past the retained edit prefix; it now shifts the
same way through one shared `editPrefixLength()` instead of landing
inside the kept content. Codex round 1 (P2) on #15692.
* 🔤 fix: Decode percent-encoded S3 keys so non-ASCII filenames are readable

extractKeyFromS3Url returns `URL.pathname` as the S3 object key without
decoding it. The AWS SDK percent-encodes the Key again when it signs the
request, so a file stored under `Ársreikningur.pdf` is fetched as
`%C3%81rsreikningur.pdf` and every read fails with NoSuchKey. ASCII keys are
byte-identical either way, which is why this only appears for non-English
filenames.

Decode keys derived from a URL path in all three branches (path-style
endpoint, bucket-in-path, virtual-hosted). A key passed in raw — not a URL —
still returns untouched, and a malformed escape sequence falls back to the
raw value with a warning rather than throwing.

* 🔗 fix: Percent-encode CloudFront URL keys so both URL forms agree

buildCloudFrontUrl interpolated the raw S3 key into the URL while SDK-generated
S3 URLs carry an encoded one, so the two forms of `file.filepath` disagreed
about what a `%` means. `assertS3FileName` permits `%`, so a key containing the
literal text `report%20final.pdf` produced a CloudFront URL indistinguishable
from one for a key containing a space — and decoding on extraction would then
target the wrong object on read, re-sign, and delete.

Encoding each path segment here (separators stay literal) makes both producers
consistent, which is what lets extractKeyFromS3Url decode unconditionally.

* style: sort imports in cloudfront/crud.ts (pre-existing drift)

The changed-file import-sort gate flags this file; the drift predates this PR
(the untouched upstream version fails the same check). Kept as its own commit
so it does not obscure the fix.

* 🔏 fix: Encode the CloudFront Invalidation Path Like the Viewer URL

`buildCloudFrontUrl` now percent-encodes each key segment, so the cached
viewer path for a key with a literal `%` or a non-ASCII character is the
encoded form. `deleteFileFromCloudFront` still handed the raw key to
`CreateInvalidationCommand`, so the invalidation no longer matched the
cached object and deleted content stayed served until the entry expired.
Both producers now share one `encodeKeyPath` helper.

Also asserts that `getS3FileStream` sends the decoded key to
`GetObjectCommand`, which is the call that actually failed with
`NoSuchKey`, rather than only checking the extractor.

Co-authored-by: dinershtein <228485+dinershtein@users.noreply.github.com>

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
Co-authored-by: dinershtein <228485+dinershtein@users.noreply.github.com>
#15686)

The DocumentDB compatibility guard flagged Mongoose per-document save-condition bag reads and writes (document.$where) in tenantIsolation.ts as the unsupported $where operator, leaving dev red on Tests: data-schemas. The guard now judges a dotted $where only where the syntax is unambiguous: a call in any form, or code assigned through any operator or wrapper, is an offense; a read or a non-literal assignment is not claimed, with filter.$where = predicate stated as the one declared limit and the method sweep and live cluster run as its backstop. Every other way of writing the operator remains an offense. unwrapExpression also peels angle-bracket assertions, closing a gap in pipeline-update detection.
#15695)

S3 and CloudFront records have carried `storageKey` since #12987, yet six
readers still handed `file.filepath` to `getDownloadStream` and re-derived
the key by parsing a presigned or CDN URL, while four others had grown
their own `storageKey || filepath` expression. One resolver now serves all
ten: `resolveDownloadPath` returns the recorded key when present and the
path otherwise, so records without a key (local, Firebase, Azure, code
output) behave exactly as before, and the share route keeps its local-only
query-string strip on top.

`resolveStoredS3Key` reuses the same `StoredFileRef` type. Covered by unit
cases for the resolver and an S3 case proving a record with a key streams
correctly even when its stored URL no longer parses.

Closes #15693
* fix: enforce FILE_SEARCH role permission server-side

The FILE_SEARCH role permission is stored, served by GET /api/roles/:name and
settable through the admin API, but nothing ever checks it.
PermissionTypes.FILE_SEARCH occurs zero times under api/server; the three
occurrences in @librechat/api are all in the interface-to-role sync, which
writes the permission rather than checking it.

Measured on v0.8.8-rc1 and confirmed unchanged in rc2: a user whose role has
FILE_SEARCH.USE = false can still upload a document with
tool_resource=file_search and gets 200 with "embedded": true. The same user
calling execute_code correctly gets 403.

Two gates, both mirroring toolAccessPermType in ~/server/controllers/tools.js:

* Upload (api/server/routes/files/files.js): a tool-resource-to-permission map
  checked before any branching, so it also covers the assistants path. Returns
  403 with the same log line as the RUN_CODE gate. execute_code is included
  because it had the same hole: the tool CALL was gated, the upload was not.

* Tool loading (api/app/clients/tools/util/handleTools.js): checked outside the
  lazy loader so a denied user never gets the tool equipped, rather than one
  that fails when called. Fails closed if the permission check itself throws.
  All four required imports were already present in that file, next to an
  existing FILE_CITATIONS check.

Both changes live in /api (JavaScript) rather than /packages/api (TypeScript)
as CLAUDE.md prefers for new backend code: each mirrors an existing pattern in
exactly these files, and a second permission gate in another language and
package would be harder to keep in step with the first.

Tests: api/app/clients/tools/util/handleTools.fileSearchPermission.test.js
covers permit, deny, the log line, and the fail-closed path.

* fixup! fix: enforce FILE_SEARCH role permission server-side

* 🔐 fix: Gate `file_search` and `execute_code` on Role Permissions

The `FILE_SEARCH` gate added in the previous commit lives in
`handleTools.loadTools`, which `loadAgentTools` only reaches when
`definitionsOnly` is false. That default is false on the chat path
(`Endpoints/agents/initialize.js`) but true on the responses and
OpenAI-compatible controllers, so on those paths a denied user still got
the tool definition advertised to the model and their files primed by
`primeSearchFiles`, and the call failed later in `loadToolsForExecution` —
the "gets one that fails when called" outcome the gate set out to avoid.

Move the decision to the capability filters that both loaders already run,
mirroring how `canUseMCP` resolves the `MCP_SERVERS` permission ahead of the
synchronous filter. `AgentCapabilities` stays the instance-wide deployment
switch; the role grant is a second condition a tool has to clear. Because
`hasFileSearch`/`hasExecuteCode` derive from the filtered list, priming is
skipped for a denied user too.

`RUN_CODE` had the same hole: `toolAccessPermType` in
`~/server/controllers/tools.js` gates only `POST /tools/:toolId/call`, not
the agent run, so a role without `RUN_CODE.USE` could still execute code
through an agent. Both tools go through the same map.

Checks use `checkAccessWithRequestCache` and run only for tools whose
capability is already enabled, so a run costs at most one role read. A check
that throws denies the tool.

Also formats the test file added in the previous commit, which Prettier
rejects as-is.

* 🔐 fix: Close the Remaining `RUN_CODE` and `FILE_SEARCH` Bypasses

Codex review of the previous commit found four ways past the gates. All four
share a cause: the permission map was copied per call site, so every boundary
that was not copied into stayed open.

Moves the maps and the check into `packages/api/src/tools/rolePermissions.ts`,
per CLAUDE.md ("all new backend code must be TypeScript in `/packages/api`",
"keep `/api` changes to the absolute minimum"), and points the four `/api`
boundaries at it.

- `handleTools.loadTools` now gates every tool in `toolRolePermissions`, not
  just `file_search`. It is the shared boundary the Assistants required-action
  flow crosses via `processRequiredActions`, which never passes the agent
  capability filter — so a legacy Assistant with a raw function named
  `execute_code` ran arbitrary code for a role denied `RUN_CODE`.

- The check is now request-cached (`checkAccessWithRequestCache`). The previous
  `checkAccess` call omitted `req`, so the agent path resolved the same grant
  twice and issued a second serial `getRoleByName` during chat startup, against
  the rule in AGENTS.md.

- `codeExecutionEnabled` is gated by the role result at all three loaders, not
  only the `execute_code` entry in the filtered list. It drives tool
  classification and the programmatic bash tool, so an agent pairing
  `execute_code` with an MCP `code_execution` tool still advertised and
  instantiated `run_tools_with_bash` for a denied role.

- The upload map covers `EToolResources.code_interpreter`, which the Assistants
  builder posts instead of `execute_code`, and native `code_interpreter` /
  `file_search` tools are dropped when an assistant is created or updated.
  Those run inside the provider and reach neither loader, so configuration time
  is the only place they can be gated.

Renames `handleTools.fileSearchPermission.test.js` to
`handleTools.rolePermissions.test.js` — it now covers both tools.

* 🔐 fix: Gate the Image Upload Route and the v1 Assistant Writers

Second Codex round found the same shape again: boundaries the map had not been
wired into. Adds two shared helpers next to the maps in
`packages/api/src/tools/rolePermissions.ts` so a new upload handler or assistant
writer gets the check by calling one function.

- `/files/images` accepts `tool_resource` and routes agent uploads to
  `processAgentFileUpload` on its own. The Code Files UI sends images there, so
  every image was a way around the upload boundary for a role denied
  `RUN_CODE`. Both upload routes now call
  `checkToolResourceUploadPermission`.

- That helper passes `req`, so the role read joins the request cache. The
  previous `checkAccess` call omitted it, and an allowed Assistants upload goes
  on to `processFileUpload` → `addResourceFileId` → `updateAssistant`, whose own
  check does pass `req` — two serial lookups per upload, against AGENTS.md.

- `/assistants/v1` is still mounted and its `createAssistant` / `patchAssistant`
  write native tools straight to the provider. Both now filter through
  `resolveAssistantToolPermissions`, which v2 also uses in place of its local
  copy.

Run-time enforcement for native assistant tools is deliberately not here: it
means overriding `body.tools` at `createRun` in both chat controllers, which
changes run semantics, and is better argued on its own.

* 🔐 fix: Gate Legacy `retrieval` and the Code-Environment File Tools

Third Codex round, same shape twice more.

- The v1 assistant builder submits `{ type: 'retrieval' }` where v2 submits
  `file_search` (`AssistantPanel.tsx:203`). Only the v2 spelling was mapped, so
  `resolveAssistantToolPermissions` treated legacy retrieval as ungated and both
  v1 writers preserved it for a role denied `FILE_SEARCH`. Both spellings now
  answer to the same grant.

- `codeEnvAvailable` in the agent initializer was capability-only, and
  `initializeAgent` rebuilds `bash_tool`, `read_file` and the workspace file
  tools from it — after the tool loader has already dropped `execute_code` for a
  denied role. The bash gate stops command execution, but those handlers still
  read, search, create and edit files in an attached code environment. The flag
  now carries the role grant, and short-circuits before the role read when the
  deployment has the capability off.

* 🔐 fix: Resolve Tool Grants Once Per Request and Pair Every Capability Gate

Four review rounds found thirteen issues, all one class: a boundary the
permission check had not been copied into. Rounds 2 and 3 each found gaps
created by the previous round's fix, and a manual sweep of tool constructors
and upload routes still missed the `codeEnvAvailable` family entirely — because
the thing worth enumerating is not "paths that reach a tool" but "reads of the
capability".

There are 28 such reads. This replaces per-site permission checks with one
resolution per request, pairs every read that is a gate, and pins the list so a
new one cannot be added silently.

- `resolveToolRoleGrants` resolves `RUN_CODE` and `FILE_SEARCH` together and
  memoizes on the request. Both perf findings disappear by construction: gates
  share one role read instead of each issuing its own.

- Newly paired gates: `codeEnvAvailable` in the OpenAI-compatible, Responses and
  memory-agent initializers (`initializeAgent` rebuilds `bash_tool`, `read_file`
  and the workspace file tools from it, and hands their handlers the code
  environment); the capability checks in agent upload processing and in
  agent-management upload purposes; and the library chat-completion service,
  behind an optional `getRoleByName` dependency so embedders are not broken.

- The v1 Knowledge upload posts `assistant_id` with no `tool_resource`, so there
  is no resource to authorize. It now reads the assistant's own native tools and
  requires their grants, instead of treating a missing resource as unrestricted.

- The startup role read moved off the critical path into the existing
  `Promise.all`, and the direct tool-call check passes `req` so the shared
  loader gate reuses its result rather than repeating the lookup.

- `toolCapabilityGates.spec.js` pins every file that reads either capability
  with a count and a reason. A new read fails the suite until it is classified,
  so the next boundary announces itself instead of waiting for a reviewer.

* 🔐 fix: Abort Assistant Writes on Role-Lookup Failure, Authorize Legacy Uploads First

Round 5 found no missed boundary — the inventory held. All three findings are
about how the gates behave.

- Failing closed is right where a denial blocks the operation, and wrong where a
  denial instead filters a payload the caller persists. A transient role-store
  outage during an assistant create or patch was silently stripping
  `code_interpreter` / `file_search` / `retrieval` and saving the result, turning
  a momentary failure into permanent config loss. `checkToolRolePermission`
  gains `throwOnError`, and `resolveAssistantToolPermissions` uses it: every one
  of its callers either persists the filtered list or rejects the request, so a
  lookup failure has to propagate.

- The legacy-assistant upload gate ran after `sanitizedUploadFn` had already
  pushed the file to the provider, so a denied role left an untracked remote file
  and got a 500 for what is a 403. Moved to the route, next to the tool-resource
  gate, before any bytes are sent.

- `resendFiles` hydration derived its resource set from unfiltered `agent.tools`,
  so a denied role still paid the thread walk and code-file reads for a tool
  about to be dropped. The `execute_code` half now keys off
  `effectiveCodeEnvAvailable`, which already carries the grant.

The `file_search` half of that last one needs the grant threaded into
`initializeAgent`, which is plumbing in the most delicate file here for a latency
win on an already-correct path — deliberately left for a follow-up.

* 🧪 fix: Declare `resolveToolRoleGrants` in the Upload Spec's Module Stub

`Tests: api (shard 2/3)` failed on `a0b38c3`: `process.spec.js` stubs
`@librechat/api` wholesale, so the `resolveToolRoleGrants` call added to the
upload gate resolved to undefined and threw. Five failures in
`processAgentFileUpload`, plus one cascading `processFileURL` assertion from the
same suite.

The gate was verified through its own spec and the routes above it, but not
through the spec of the file it lives in — `process.spec.js` was never run
locally. Every spec belonging to a file this PR touches now has been, and the
mongodb-backed ones that still cannot run here were checked statically for the
same stub shape: all but `process.integration.spec.js` spread `requireActual`,
and that one never reaches these paths.

* ⚡ fix: Skip the Role Lookup When Code Execution Is Disabled

The browser initializer already guards this; the OpenAI-compatible and Responses
controllers did not, so every request on those endpoints issued a role read whose
result the capability short-circuit then discarded. Both now start the lookup
only when the deployment has `execute_code` enabled, matching the third.

* ⚡ fix: Drop Three Redundant Reads on the Gated Paths

- The memory initializer resolved grants unconditionally, so a deployment
  without `execute_code` paid a role read for a flag that could only be false.
  Guarded like the three chat initializers.

- Agent-management upload purposes resolved grants before `checkCapability`, so
  a purpose the deployment has switched off paid a role read on the way to being
  rejected. Capability first, then the grant.

- The legacy-assistant preflight builds an OpenAI client to read the assistant's
  tools, and `processFileUpload` then built a second one — re-reading the user's
  key expiry and values. The preflight now hands its client on, and processing
  builds one only when it wasn't given one.

---------

Co-authored-by: Paul <200737214+SSIG-IT@users.noreply.github.com>
#15691 paired every read of `AgentCapabilities.execute_code` / `file_search`
with its role grant, and skipped `execute_code` from the resend-file priming
when `RUN_CODE` was denied. It left the `file_search` half: the grant was not
available inside `initializeAgent`, so a role denied `FILE_SEARCH` still had
its prior-turn search files re-hydrated on every resend.

Priming is not free. The files are fetched, `updateFilesUsage` bumps their
counters, and `primeResources` builds `tool_resources.file_search` — all for
a tool the loader is about to drop. A deployment with the capability off paid
the same cost.

`InitializeAgentParams.fileSearchAvailable` carries the capability AND the
grant, resolved from the same single `resolveToolRoleGrants` read that already
answers `codeEnvAvailable`, so pairing the second flag costs no extra role
lookup. Every initializer that resolves grants forwards it: the chat path and
its handoff, added-convo and discovery hand-offs, both API routes, and the
OpenAI-compatible embedder route. Absent leaves priming unconditional, so an
embedder that resolves no grant keeps its current behavior.

The loop that selected the resend tool resources becomes
`resolveResendToolResources`, which makes both halves of the gate testable
rather than reachable only through a full agent initialization.

The memory agent is untouched: it passes no `conversationId`, so it never
reaches this priming.

Co-authored-by: Claude <noreply@anthropic.com>
* fix: Bound Code Environment Status Polling

* refactor: Move Code Limiters Into API Package

* fix: enforce status polling boundaries
* fix: Require Platform Grants for Code Workers

* fix: Separate Platform Scope From Tenant Identity
* feat: Add conversation code approval modes

* refactor: Reuse shared code approval types

* fix: Scope approval selector to stateful agents

* fix: harden code approval state propagation

* fix: fail closed during approval discovery

* test: cover approval mode acceptance

* test: use visible approval mode label

* test: drive approval menu by keyboard

* test: expose accept edits in BYOM acceptance

* fix: read approval mode atomically on send

* fix: align code modes with endpoint policy

* fix: initialize code limiters at startup
…ck Icon (#15706)

* 🕓 style: Move the Queued-Message Hint Into a Hover Tooltip on the Clock Icon

"Sends when this response finishes" rendered as a caption row under the
queued chips for as long as a run was pending, costing composer height
for a single reassurance. The clock icon of each queued row now carries
that sentence as a hover tooltip and as its accessible name, and the
caption row is gone. Nothing else about the row changes: warning states
keep their triangle, and the hint disappears with the run as before.

* ⌨️ fix: Give the Queued-Hint Clock an Explicit Tab Stop and Focus Ring

The tooltip anchor was already focusable through Ariakit's Focusable
default, and opens on focus-visible, but nothing said so and no ring
showed where focus was. Make the tab stop explicit, add the same
focus-visible ring the row's buttons use, and prove the keyboard path
with a test. The hover test now allows for Ariakit's show timer on a
loaded CI shard, which is what failed the first run. Codex round 1 (P2)
on #15706.

* 🧪 test: Move the Pointer Between Coordinates Before Expecting the Queued Hint Tooltip

Ariakit's hover detector treats a pointer as moving only when consecutive
mousemove events differ in screen coordinates; its NODE_ENV=test shortcut
does not apply under the CI runner's NODE_ENV, which is why the hover test
passed locally and failed in the shard. Two moves at distinct coordinates
make the test environment-independent; the keyboard test was already
deterministic.
* fix: Clean Up MCP OAuth Teardown State

* fix: Harden OAuth Teardown Boundaries

* fix: harden MCP OAuth teardown races

* fix: avoid flow cache dependency cycle

* fix: bind OAuth teardown to credential generations

* test: cover active OAuth server fixtures

* style: sort OAuth cleanup imports

* fix: discard mismatched OAuth token snapshots

* fix: fence OAuth teardown across callback races

* test: align OAuth callback fixtures with server fencing

* test: complete OAuth callback server fixtures

* fix: version OAuth flow settlement

* fix: fence OAuth teardown snapshots

* test: align OAuth teardown fixtures

* test: complete OAuth snapshot fixtures

* fix: snapshot OAuth deletion lifecycle

* test: bind OAuth revocation snapshots

* test: assert per-record OAuth fence

* fix: harden MCP OAuth teardown consistency

* test: expect snapshotted OAuth readers
* fix: fence concurrent MCP OAuth teardown

* fix: hold MCP refresh teardown fences

* fix: suppress OAuth fallback during teardown

* fix: retire MCP connections before OAuth cleanup

* fix: annotate refresh teardown tenant scope

* fix: fence OAuth teardown across replicas

* test: provide OAuth teardown leases in controller mocks

* fix: fail safely around OAuth teardown leases

* fix: complete OAuth lease lifecycle handling

* fix: preserve OAuth single-flight admission
* fix: recover direct OpenID MCP bearer sessions

* fix: harden direct bearer recovery lifecycle

* fix: close direct bearer lifecycle races

* test: align direct bearer checks with current dev

* fix: unify direct bearer recovery budget

* fix: infer direct bearer recovery from placeholder

* fix: preserve bearer precedence and recovery budget

* fix: propagate bearer recovery across joiners

* fix: preserve direct bearer fallback without session provider

* fix: close direct bearer recovery race windows

* chore: sort bearer recovery test imports

* fix: align bearer recovery with OAuth cleanup lifecycle

* fix: fence request-scoped MCP creation during config mutations

* fix: retain suspended MCP catalog authentication outcomes

* fix: cancel MCP bearer recovery and reuse refreshed credentials

* fix: close MCP bearer recovery lifecycle boundaries

* fix: preserve ordered catalog authentication rejection

* fix: isolate bearer recovery across request and transport boundaries

* fix: preserve shared recovery ownership and credential precedence

* fix: wait for recovery borrowers and validated OpenID publication

* fix: observe the validated browser publication flight

* fix: cancel abandoned OpenID publication followers

* fix: fence refresh grant admission after lease acquisition
* 🗜️ feat: Manual Context Compaction as a Summarize-Only Turn

Adds a user-triggered "Compact context" action that summarizes the active
branch on demand. The compaction is an ordinary agent turn: the client
submits it through the normal chat pipeline with `compact: true`, the
controller hangs it off the branch's leaf instead of creating a user
message, and the agent client runs the graph summarize-only (the SDK's new
`summarizeOnly` input), so the summary streams into the response
placeholder under the leaf exactly like the automatic detour's and is
persisted as the boundary every later turn starts from.

Nothing new is needed for job registration, billing, abort, persistence,
or the usage snapshot: the run emits the same summary step and post-summary
context snapshot the automatic path already emits, and the response message
carries the summary part plus the `summaryUsedTokens` baseline the gauge
reads. The controller validates the request (existing conversation, a real
leaf, summarization enabled, no edit/regenerate/continue combination) and
answers 409 when the leaf is already a bare summary.

Client-side the submission borrows the regenerate shape — no new user
bubble, the placeholder parents onto the leaf — while the wire payload
drops `isRegenerate` so the server never treats it as a regenerated user
turn. The action sits in the context-usage popover, gated by the same
`summarization.enabled` switch as the automatic detour.

* 🗜️ fix: Localize a Compaction That Cannot Run

A compaction the graph could not attempt (summarization disabled, the
instructions already exceeding the budget, or an explicit recency window
covering the whole conversation) rejects with the SDK's
`ManualSummarizationSkippedError`, which rendered as the generic admin
error. It now maps to a typed `compaction_skipped` payload with the reason,
and a run that ends with neither a summary nor a recorded error fails as
`compaction_failed`; the Error component localizes both. A run that already
recorded why it stopped keeps that error part instead of failing twice.

* 🗜️ fix: Anchor a Compaction on the Leaf Everywhere It Is Read

Review round 1.

- The client sent the leaf's parent as `parentMessageId`, which the
  controller treats as the anchor, so the summary would have skipped the
  latest reply and landed as its sibling. The leaf's own id is now both the
  placeholder's parent and the server-side anchor.
- A compaction has no user message of its own, and every consumer of the
  job's user-message slot now knows it: the error path parents its row on
  the loaded anchor and never upserts the leaf, and the job records the
  regenerate shape so a reconnecting client rebuilds the turn under the
  leaf instead of recreating it as a user message.
- The controller no longer reads the anchor before the branch is loaded;
  `BaseClient` validates it against the history it loads anyway and
  answers 404 for a missing leaf or a typed `nothing_to_summarize` for a
  finished compaction. That predicate is shared through the data provider
  and ignores a summary still streaming or one that failed, so an
  interrupted compaction can be retried.
- A compaction never consumes files staged in the composer, the action is
  shown only when the server advertises `compactionEnabled`, and the
  in-flight marker is feature-local Jotai state rather than a read of the
  Recoil submission store.

* 🗜️ fix: Keep a Compaction Turn to Its Summary

Review round 2, plus the class it belongs to. A compaction submits no user
turn, so nothing that keys off one may run: the memory pass no longer runs
over the history (it would have spent a model call and could repeat memory
writes), and skill primes are no longer injected into the transcript the
run is about to summarize.

The client's in-flight marker is also reconciled on mount: a compaction
that finished while the view was away left the marker set, and the next
ordinary turn in that conversation would have shown as compacting.

* 🧹 chore: Sort Imports in the Compaction Hook Spec

* 🔧 chore: Update @librechat/agents dependency to version 3.8.1 in package-lock.json and package.json files
* 🧐 feat: Review Pending Tool Actions in the Composer (#15714)

* feat: add interactive approval review to the composer

* fix: Harden interactive approval boundaries

* fix: Bound every approval display label

* test: exercise composer approval review in BYOM acceptance

* fix: reveal all directional approval controls

* fix: canonicalize code approval targets across replicas

* style: sort resumable approval test imports

* test: Exercise Coexisting Approval Surfaces

* Revert "🧐 feat: Review Pending Tool Actions in the Composer (#15714)"

This reverts commit 5f79ded.

* Reapply "🧐 feat: Review Pending Tool Actions in the Composer (#15714)"

This reverts commit 52c7d9c.

* chore: bump agents sdk to v3.8.2
* 🔒 fix: Isolate Checkpoint Cleanup by Generation Ownership

* 🔒 fix: Harden Checkpoint Cleanup Ownership

* 🔒 fix: Retain Checkpoint Cleanup Evidence

* 🔒 fix: Persist checkpoint cleanup receipts

* 🔒 fix: Bound checkpoint cleanup receipt lifecycle

* fix: Bind checkpoint cleanup to durable ownership and deletion intent

* fix: Bind event actor checkpoint storage to authenticated owners

* fix: Resolve exact actor checkpoint ownership before lifecycle cleanup

* fix: Preserve actor storage compatibility with durable ownership and prune work

* fix: Defer checkpoint erasure until conversation deletion commits

* fix: Reclaim orphan actor ownership and batch deletion lookups

* fix: bind actor checkpoint ownership to payload rows

* fix: isolate actor readers and await account persistence

* fix: gate owned suspensions and drain complete account state

* fix: unify checkpoint deletion lifecycle and storage authority

* fix: Replay checkpoint deletion from durable owner state

* fix: retain checkpoint cleanup identity before topology loss

* fix: complete checkpoint catalog capture and retirement lifecycle
lia-by-librechat Bot and others added 26 commits September 20, 2026 08:28
Classify HTTP 408, 429, and 5xx before legacy body-message classifiers can invalidate client registration or start consent. Preserve typed temporary failures across refresh storage and verify retry with the existing grant against the real SDK test provider. Keep permanent grant errors and existing UI retry behavior intact.

Co-authored-by: Lia <lia@librechat.ai>
…16120)

* 💵 feat: Show Cost per Record, Step and Response in the Trace Ledger

* 💵 fix: Cost for Whole Responses Only, Hidden Records Included, and Said Aloud

* 💵 fix: Give the Oldest Loaded Response No Cost While Older Records Remain
* 🥊 test: Prove MCP Refresh Coordination Across Replica Processes

Fork real replica processes with production Mongo, Redis, encryption and OAuth adapters, and assert one redemption per rotation, peer adoption, and recovery after the authorizing replica is killed. A negative control with coordination disabled observes two redemptions, so a green coordinated run cannot pass on timing alone.

Adds a refreshGate hook to the shared OAuth test provider so concurrent refreshes can be held open. No production code changes.

* test: Establish Replica Overlap Instead of Assuming It

The coordinated assertion counted provider redemptions after a fixed delay, so a peer that arrived once the winner had already persisted would read a fresh credential, never contend, and still leave one redemption: the test would have passed while proving nothing. Both replicas now report entering the refresh path on a credential they observed expired, and getTokens own onRefreshSuccess/onTokensAdopted hooks report which side each took, so the pair (one redeemed, one adopted) is asserted rather than inferred from timing.

A worker that never signalled ready was also left running with live Mongo and Redis connections, which would outlive the suite and disturb the rest of the integration lane; boot failures now reap the child and report the workers own initialization error.

---------

Co-authored-by: Lia <lia@librechat.ai>
* 🧹 fix: Build the Legacy Meili Cleanup Index MongoDB Rejected

`meili_excluded_legacy_cleanup_v3` declared `_meiliCleanupVersion: { $exists: false }`
in its `partialFilterExpression`. MongoDB rewrites that to `$not`, which no partial
index accepts, so the build failed on every startup for Message and Conversation and
the index never existed.

The missing-version condition moves into the key, where a missing field indexes as
null and the legacy cleanup query reaches it on the same scan. The filter keeps the
two conditions a partial index can express, and the name moves to `_v4` so no
deployment that somehow holds the old spec hits an options conflict.

* 🎯 fix: Bound the Legacy Cleanup Index to Documents Awaiting Cleanup

The buildable filter admitted every excluded document carrying `_meiliIndex: false`,
which the schema writes on every new subagent message and conversation. Those
documents already carry the current `_meiliCleanupVersion`, so the cleanup query never
selects them and their entries never leave the index.

The unstamped state is now expressed as a null equality, which a partial filter
accepts and which matches an absent or null version. The legacy cleanup branch asks
the same way, so the planner can still reach the index, and a document leaves it as
soon as cleanup stamps the version.
…16037)

* ⚓ fix: Keep a Resumed Compaction Anchored to the Turn It Summarizes

A compaction submits no user turn: its user-message slot names the leaf it
summarizes up to. The resume paths adopted that identity-only projection as a
ROW, rewriting the answer being summarized into an empty, parentless user
message, which buildTree files as a phantom root: the thread folded and the
pane could return on another branch. Treat the slot as an anchor everywhere
rows are written, and let the anchor name the branch to restore.

* Recognize a compaction anchored on a user leaf

Compact runs on whatever leaf the branch ends with and canCompact does not restrict its author, so the anchor is a user message as often as an answer. Detecting it by not-user-created recognized only the assistant-leaf kind: the other was rebuilt as an ordinary turn, so the sync path merged the identity-only projection over the stored row and blanked the prompt still on screen, and a failed abort appended a second row under its id. Judge the anchor by the projection shape instead, and carry the resolved answer on the resumed submission so a flagless re-attach agrees.

---------

Co-authored-by: Lia <lia@librechat.ai>
* fix: bind preserved MCP API keys to server configuration

* fix: surface MCP API key rebinding errors

* test: use typed MCP OAuth re-entry error
* test: add deployed instance Playwright smoke profile

* test: normalize deployed smoke account state

* test: harden deployed smoke lifecycle

* test: secure deployed smoke cleanup
* 🫧 feat: Hold a Live Run's Activity at One Row

Fold the span a run is still writing into a single collapsed row from its first tool call, with a header that bubbles up the newest activity (streamed intent, generic running text, filled batch label, or the thought streaming after them) on a 500ms leading+trailing throttle. The settled rendering is unchanged.

* 🫧 fix: Name Every Live Line and Take the Fold Preference From the Host

Live header resolves failed/cancelled through resolveToolCallPhase, names trailing commentary, and only folds agents-shaped calls it can name. ContentParts takes foldLiveActivity from its host instead of reading Recoil; subagent panels opt out.

* 🫧 fix: Carry Background Stops and Nested Sign-Ins Into the Live Fold

* 🫧 feat: Preview Tail Reasoning One Sentence at a Time in the Live Row

* 🫧 fix: Keep Handoffs, Detached Failures and Status Announcements Out of the Fold's Blind Spots

* 🫧 fix: Keep the Live Line's Shimmer Off the Ticker's Animated Element

Found in Chromium: .shimmer declares animation/position/display, so on the same element it replaced the slide-out and the retired line never left. The sweep now rides an inner span, and the header button is block-level flex so a live row measures the same 28px as the settled row.

* 🧹 chore: Sort Imports in the Live Activity Helper

* 🫧 refactor: Decide a Live Line's Outcome With the Group Header's Resolver

The live header derived pass/fail on its own and re-learned, one review at a time, signals the cards already handle. getToolMeta moves out of ToolCallGroup into Content/outcome.ts and the live line reads it, which covers cancelled status attachments and memory-tool prose failures. A legacy Assistants call now ends a live span like a handoff, and the live disclosure is named by its current line alone (aria-labelledby) so the polite region's previous line stays out of its accessible name.

* 🫧 fix: Make the Live Row Agree With the Cards It Hides

Span-level outcome beside the newest line (an earlier call can fail while a later one runs), announcements with their own identity in one polite region that outlives the live header, the memory-error artifact and step-scoped attachment ownership in the shared resolver, one localized 'Failed: {{0}}' template for the card and the row, and a parity suite that compares the folded row with the REAL cards on unmocked outcome logic.

* 🫧 fix: Keep Live Subagents Unfolded and Give Reused Ids and CJK Sentences Their Own Lines

* 🫧 fix: Hold a Finished CJK Sentence and Honor Open Thinking in Live Folds

* 🫧 fix: Say "Running in Background" as the Card Does and Hold Reasoning Identity Across Whitespace

* fix: Harden live activity folding boundaries and lifecycle

* 🫧 feat: Surface a Span's Own Glyphs on Its Collapsed Header

A header stands for rows it hides, so it carries the most specific glyphs they show. The icon stack swaps the generic search glyph for the sites a web search read (from attachments, or the streamed results while live), and the settled phase header keeps the span's tool, MCP and site icons instead of a bare check; a failed phase keeps its warning glyph. Applied to the live row, the settled phase header and the tool group header. Source helpers move out of WebSearch into Content/sources.ts.

* fix: Preserve owned glyphs and outcomes across collapsed headers

---------

Co-authored-by: Lia <lia@librechat.ai>
* ⚡ perf: Split Streaming Markdown Blocks Incrementally

* test: cover streamed markdown block rendering

* fix: isolate streamed markdown splitter caches

* fix: satisfy markdown splitter static checks

* test: mock isolated markdown splitter

* test: isolate markdown splitter spies

* test: cover concurrent streamed markdown messages

* fix: preserve provisional streaming markdown boundaries

---------

Co-authored-by: Lia <lia@librechat.ai>
…16070)

* ⚡ perf: Exclude Agent Version History From Default `getAgent` Reads

getAgent now defaults its projection to { versions: 0 }; loadAgent/loadAddedAgent
and canAccessAgentFromBody use getAgentWithVersionCount so the version count stays
exact without transferring the unbounded versions array on every chat request.
Callers that need history (v1 update/revert handlers) request it explicitly.

* fix: tighten agent version projection types

* fix: type projected agent version counts

* fix: type default agent projection

* style: format agent projection overload

* fix: expose projected agent return type
* ⚡ perf: Preserve Message References in the Content Handler

* test: cover content handler message reconciliation

* fix: preserve thread metadata for incomplete content events

* fix: retain fallback response thread identity

* fix: select parent from response thread

* fix: retain untagged conversation messages

* fix: preserve history while resolving parent

* fix: refresh cached response metadata

* fix: honor synchronized parent metadata

* fix: scope parent fallback to response thread

* fix: avoid cross-thread parent fallback

* fix: keep content handler selection semantics
* ⚡ perf: Virtualize the Model Selector's Search Results

* fix: make model search accessibility metadata global

* test: stabilize model selector search scenarios

* test: assert model selector keyboard semantics

* test: simplify model selector keyboard assertion

* test: exercise keyboard pin navigation

* fix: preserve search result announcements

* test: follow active row pin through keyboard

* test: scope pin toggle assertion

* test: scope pin toggle to active row

* test: stabilize keyboard pin scenario

* test: isolate model selector favorites

* fix: preserve virtual search boundary navigation

* test: cover virtual search boundaries

* fix: own virtual boundary by logical position
…6143)

`useLazyHighlight` tokenized the same input twice on every mount that could
highlight immediately. The state initializer highlighted during render whenever
the grammars were already loaded, which holds for every card after the first in
a session, and then the effect highlighted the same input again because its
dedupe ref started empty and could not match on a fresh mount. Each card paid
two tokenizations, two hast-to-React conversions and an extra commit.

Card bodies also tokenized while collapsed. Every code, bash, read-file and
file-authoring card highlighted its content on mount, so opening a long agent
conversation highlighted code nobody had asked to see.

The tokens now carry the key they were produced from, so the effect recognizes
what the initializer already did, and each call site passes its content only
while its pane is open. ExecuteCode gains the raw fallback its three siblings
already had: with highlighting deferred to the pane opening, rendering only the
highlighted nodes would leave the pane empty until the grammars load.
…d Thoughts (#16145)

* 🫧 fix: Fold a Streaming Thought Into the Live Row Before Its First Tool Call

A thought at the live tail rendered as its own row with a multi-line peek until a tool call arrived, which then snapped it into the one-row fold. With a reasoning model that talks between calls that is a jump up and back down on every step. The span now folds from the thought's first character and previews it one sentence at a time, under a reasoning glyph until a tool gives it icons.

* 🫧 fix: Keep a Live Card's Key When Its First Tool Call Arrives

* 🫧 fix: Stop Unfolding the Whole Live Span for Every Code Call Without a Leading Intent

blocksLiveFold unfolded the entire span while a bash/code call with complete args, no output and no intent was running, so its card could show the sandbox-startup label. When the model writes intent after command, every code call qualifies: a 47-call run opened to its full height and shut again on each call. The row now reads the same sandbox signal for its newest call and the span stays one card. Also keeps tool-anchored card-key aliases across finalization so a reasoning-led card a reader opened stays open when the response settles.

* 🧹 chore: Sort Imports in ActivityPhaseGroup

* 🫧 fix: Preserve Reasoning Disclosures and Isolate Streaming Siblings

* 🧪 test: Keep Real Part Identity in Content Renderer Mocks

* 🫧 fix: Migrate Sandbox Startup Readers and Writer to Jotai

---------

Co-authored-by: Lia <lia@librechat.ai>
`style.css` pinned every `code` and `pre` element to
`Consolas, Söhne Mono, Monaco, Andale Mono, Ubuntu Mono, monospace !important`.
The repository ships none of those faces, so Windows rendered code in Consolas
and macOS in Monaco, which carries neither an italic nor a bold face for the
browser to use. `!important` also outranked the 21 `pre` and `code` elements
that ask for `font-mono` by class, so the self-hosted Roboto Mono the app
already bundles was never used for code anywhere.

Move the stack to `theme.fontFamily.mono`, where `sans` already lives, so
Tailwind's preflight styles the bare elements and the `font-mono` utility
carries the same value. The tail is ordered so the glyphs the bundled latin
subset omits keep Roboto Mono's advance width.

Co-authored-by: Lia <lia@librechat.ai>
* 🖼️ fix: Resolve Pinned Model Icons from Endpoint Config

- pass `endpointsConfig` and `model` to `MinimalIcon` from the pinned favorites row so a custom endpoint's `iconURL` is honored

Fixes #16088

* test: Cover Pinned Model Endpoint Config Propagation

---------

Co-authored-by: Lia <lia@librechat.ai>
* fix(client): map image/svg+xml artifacts to a renderable template (#16087)

* fix: Rebuild the SVG preview shell from edited source

An SVG artifact previews a derived index.html holding a copy of the
source, so replacing only the edited index.svg left the preview on the
original drawing. Expose the derived-file builder as deriveFiles and
rebuild the whole set from the editor text in SandboxArtifactTabs.

* fix: Scope the SVG preview sizing rule to the root viewport

CSS overrides SVG presentation attributes, so the unscoped `svg` rule in the
preview shell stretched every nested `<svg>` viewport to the panel, corrupting
the layout of sprites and inset diagrams. Scope it to `body > svg`.

---------

Co-authored-by: Frank_zhu <58329837+Frank-zhu0404@users.noreply.github.com>
Co-authored-by: Lia <lia@librechat.ai>
* 🌁 fix: Stop Painting the Closed Mobile Drawer

* 🌁 fix: Re-assert the unpainted value when the slide settles

React owns the resting value and writes it when the travel window closes at
TRANSITION_MS; the release ran a buffer later and cleared the property, which
dropped that value while React still believed its unchanged prop was applied.
React never wrote it again, so every animated close left the drawer painted and
the artifact could recur. Re-asserted through one shared constant, the same way
the drawer width already is.

---------

Co-authored-by: Lia <lia@librechat.ai>
* ✒️ fix: Persist a Generated Title Before the Turn Ends

* 🧷 fix: Keep the Early Title Save From Rewriting Message References

* 🩹 fix: Carry the Recovered User Message Reference

* 🧵 fix: Reference Every Recovered Message Row

* 🪢 fix: Reference Paused and Resumed Response Rows

---------

Co-authored-by: Lia <lia@librechat.ai>
* fix: preserve tool pane choices through response finalization

* test: read real message context in content part mock

---------

Co-authored-by: Lia <lia@librechat.ai>
* ⚡ perf: Throttle Code Highlighting While a Message Streams

* fix: harden streamed code highlight throttling

* test: cover wall clock changes during highlighting

* style: tidy highlight regression test

* fix: restart highlight work across lifecycle changes

* style: avoid nested highlight timing ternary

* fix: propagate highlight throttle to startup config

* fix: share and validate highlight throttle

* fix: keep streamed code visible during highlighting

* test: stream highlight tool arguments

* fix: preserve highlights when cadence config resolves

* fix: reschedule highlights when cadence changes

* test: preserve default fake tool chunking

* fix: prevent stale streamed highlights

* test: open the tool pane before asserting streamed highlights

Every scenario in this file waited for highlight tokens without opening the
card. Dev's #16143 stopped passing a closed pane any code at all, so the four
runs polled a card that renders raw text and had nothing to find: the failed
traces show the disclosure at aria-expanded="false" for the whole run, with no
click against it and no tokens in the code element.

Highlighting is now asserted through the card's disclosure, which ProgressText
owns and which carries an aria-expanded state only once the card has input to
show, so waiting on it also waits for the first streamed argument chunk. The
open happens inside expectHighlightedCode, so a scenario cannot assert tokens
without it, and it lands while arguments are still streaming, which is the only
window where the throttle is under test at all.

* test: reopen the rebuilt card for the settled highlight

Opening the pane got the streaming assertions passing and the traces show 240
highlight spans mid-stream, so the throttle itself lands. The settled check
still failed because persisting the streamed message rebuilds the card, and a
rebuilt card starts closed while autoExpandTools is off: the disclosure goes
back to aria-expanded="false" and a closed pane renders raw text.

The settled and cancelled states now assert through the same open-then-wait
helper, so each one measures the card the reader would actually be looking at
instead of the tokens the streaming card happened to leave behind.

* test: leave a window to cancel a streaming highlight

With the settled assertions fixed, the cancellation scenario timed out on the
Stop button instead: it waited for a highlight before stopping, and the ~40
argument chunks it streams at 35 ms are gone in under two seconds, so the run
had already finished and there was nothing left to cancel. It failed on all
three attempts.

The card is now opened and the run stopped before any token is awaited, since
tokens after cancellation are what the scenario is about, and the cancel
variant streams its arguments at a wider cadence so the window does not depend
on how loaded the runner is. Only that label changes cadence; every other
scenario keeps the timing it had.

* fix: preserve initial highlights and honor shared-page cadence

---------

Co-authored-by: Lia <lia@librechat.ai>
@pull pull Bot locked and limited conversation to collaborators Sep 21, 2026
@pull pull Bot added the ⤵️ pull label Sep 21, 2026
@pull
pull Bot merged commit 86c5884 into innFactory:dev Sep 21, 2026
23 of 29 checks passed
@github-actions
github-actions Bot changed the base branch from main to dev September 21, 2026 14:06
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.