Commit graph

2184 commits

Author SHA1 Message Date
Dustin Healy
7c71d6dc1a
🛂 fix: Preserve OpenID Re-Auth Errors Through MCP Tool Error Classification (#15010)
The MCP tool-call catch block classifies errors by message substring, so an OpenIDReauthRequiredError raised during header resolution was rewritten into an MCP OAuth configuration prompt and its class identity discarded. The typed error now passes through ahead of the heuristic, so the actionable re-authentication message reaches the caller intact.
2026-08-19 14:21:39 -04:00
Danny Avila
5e3c680761
🪃 feat: Wake Parent Agents on Child Completion (#14975)
* feat: wake parent agents on child completion

* wip: harden child completion wakeup lifecycle

* fix: close the completion-wakeup static failures

Type the durable-claim store fixture, the continue-envelope test helper,
and the terminal message's task metadata so the wakeup suites compile
against the shapes they actually exercise. Replace `Array.prototype.at`,
which the package target library does not provide.

Capture the prepared child thread in a non-optional local before the
provider callback closes over it, and narrow the trigger envelope itself
on `mode === 'continue'` rather than a separately copied mode, so reading
the continue target is sound. Lift the parent-message fallback out of a
nested ternary into a named resolver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: cover the active-predecessor admission fence

The Redis job-creation call gained a thirteenth scalar argument, so the
spec helper reconstructed the HSET pairs one slot early and rebuilt an
invalid job hash; three creation tests failed on that alone.

Give the fence itself direct coverage in both store adapters, which it
had none of despite deciding whether an automatic continuation may
replace a live parent turn. Each proves a running and a requires_action
predecessor are refused with the state a controller needs for a finite
409, that an absent or settled predecessor is admitted, and that an
ordinary user turn without the policy still replaces its predecessor.
The Redis case also asserts a refused continuation leaves the parent's
durable job and chunks untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: harden completion wakeup rollout and claims

* fix: close completion wakeup race windows

* test: keep the child store fixture exact

* fix: close final subagent wakeup gaps

* fix: preserve ambiguous completion claims

* fix: release pre-admission wakeup claims

* fix: stabilize subagent completion recovery

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 12:18:31 -04:00
Danny Avila
259f1e0c32
🛰️ feat: Route Live Subagent Controls Across Replicas (#14971)
* feat: route live subagent controls across replicas

* fix: initialize task routing in cluster workers

* fix: harden cross-replica task routing

* fix: expire routed task owners independently

* fix: close cross-replica routing edge cases

* fix: bound owner refresh and close routed cancellation gaps

Refresh owned task registrations in bounded parallel batches so a full
heartbeat pass stays well inside the 30-second directory lease instead of
serializing one Redis EVAL per registration.

Route conversation-deletion cancellation through a dedicated owner-side
scope operation. The owner applies the deletion predicate to its complete
local task set, so a scope holding more children than the model-facing
list cap no longer leaves live executors running after their parent is
removed.

Key a consumed claim's retained response by its operation rather than by
one caller's correlation id, so a later poll recovers a terminal result
whose responses were all lost. Live claim statuses stay uncached so a
poll always observes the task's current state.

Type the model-facing `maxLength` bounds with a narrow local string
schema; the SDK's JsonSchemaType does not declare the keyword, and the
runtime checks continue to enforce the same limits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: retain claimed results apart from control replays

A consumed claim is the only routed response whose loss destroys data, so
it no longer shares one bounded cache with control replays that unrelated
command traffic can evict. Claims are retained under their own budget, and
the requester acknowledges a result it received so the owner releases the
copy immediately instead of holding it for the full replay window.

Resolve the post-delete cancellation pass from durable leases. The deleted
conversations cannot be read back, so re-reading each one only scaled the
cascade while probing the owner directory once per removed id; one lease
read now resolves every live child address instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: never consume a result the owner cannot replay

Retention for consumed claims is bounded, so a burst of undelivered results
could evict an earlier one and lose it for good. The owner now admits a claim
only while it can retain a worst-case result, and refuses the routed claim
otherwise instead of consuming it, leaving the result on the task for a later
poll. Retained claims are never displaced; control replays keep evicting.

Key a control replay by the command itself rather than by one caller's
correlation id. The transport's own retry reuses a single envelope, but a
caller that saw the owner as unavailable reissues the command under a new id,
which steered, queued, or interrupted the child a second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: own a claimed result until it is acknowledged

A consumed terminal result is task-owned state, not a cache entry. It now
carries no expiry at all: the owner holds it until a caller acknowledges
receipt, and only then is it released. Retention stays bounded by the
existing admission gate, which refuses a claim the owner could not keep
rather than consuming a result it might drop.

Identify a control by the caller's invocation instead of by its content.
The tool mints one id per invocation and routing carries it, so a routed
retransmission of that invocation replays the owner's result while two
deliberate identical commands arrive under distinct ids and both apply.
Content-derived identity could not tell those apart and would have
answered the second from a stale snapshot.

Wait for the dpkg frontend lock in the best-effort Playwright font step.
Its timeout kills npx while the apt-get it spawned keeps the lock, which
then failed the fatal Redis install and ended the MCP replica jobs before
any test ran (#14983).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: treat acknowledgement as part of delivering a result

Publishing an acknowledgement once and ignoring the outcome meant a result
could be reported as delivered while the owner never learned it could let
go, and since that retention neither expires nor evicts, enough lost
acknowledgements would fill it and refuse every later remote claim.

An acknowledgement is now confirmed: publishing to zero subscribers is not
success, it retries inside the ordinary request window, and a claim whose
acknowledgement cannot be confirmed reports the retryable unavailable path
instead of handing back a result the owner still holds. A later poll
recovers that result and acknowledges it, and releasing is idempotent.
Owner registration also outlives the task while a result is unacknowledged,
so the retained result cannot become unreachable.

Take the control invocation identity from the provider's tool-call id
rather than minting one per execution, so replaying the same tool call
stays idempotent while two distinct calls with identical payloads both
apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* style: sort the widened node:crypto import

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: own control invocations and cancellation plans at the task seam

Applies one logical control exactly once for its owning task rather than
in the transport, so a local caller and a routed caller of the same
invocation agree, and reusing an invocation id for different content is
refused instead of silently applied. Invocation identity now comes from
the run, agent, and provider tool-call id hashed to a bounded 32
characters, so a repeated `call_0` never bleeds across tasks and no id can
overrun the routed bound.

Cancellation for conversation deletion is now resolved into a plan while
those rows are still readable, then replayed against the owner directory
after the cascade is deleted. Owner registration is awaited before any
provider work, so a child that cannot be addressed never starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* style: separate the control invocation map from the next member

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: close subagent deletion, claim, and control invocation gaps

Bulk conversation deletion now runs behind a durable owner admission fence.
Draining alone could not close the race: a child admitted on another replica
after the drain read its leases would start provider work against a parent
about to disappear. The fence is written before any lease is read and each
child revalidates it after its own lease is written, so one of the two always
observes the other. It expires on its own, so a process lost mid-deletion
cannot leave an account unable to run subagents.

A terminal child result is no longer kept alive in the owning replica's
memory until someone acknowledges it. Collection is recorded durably on the
child's own message against the polling invocation, so the poll whose
response was lost recovers its own result while a different invocation is
told the result was already collected. Owner-side retention returns to an
ordinary bounded cache that expires, which is what abandoned polls needed:
they can no longer occupy claim capacity until the process restarts.

The deletion drain now cancels each task under one invocation held for the
whole drain, stops re-sending once the owner answers, and retries only
deliveries it could not confirm. A routed control replay also validates the
command fingerprint, so one invocation id carrying different content reaches
the owner to be refused instead of collecting the earlier command's success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: assert the drain's calls before restoring its spies

Restoring a spy also clears its recorded calls, so the drain assertions
ran against an emptied mock. Formats the durable claim method tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: close the follow-on gaps in the deletion fence and result claim

The admission fence now carries an ownership token, so an overlapping
deletion's fence is never lifted by the one that finishes first, and both
fence writes invalidate the cached auth user document. It also covers the
other bulk-delete path: `DELETE /` with no conversation filter removes every
conversation, so it runs behind the same fence rather than a bare drain.

The durable record now decides who holds a one-shot result. An owner replaying
a retained response could hand the same terminal claim to a second invocation;
that invocation is told the result was already collected, while the one that
consumed it still recovers its own. A task with no durable record to arbitrate
keeps whatever the owner answered.

Drain cancellation treats `not_found` as unconfirmed: a missing registration
while the durable lease is still live means the child may be running, so the
command is retried under its invocation once the owner republishes itself.
Control fingerprints are hashed, so retaining one per invocation costs a fixed
few bytes instead of a bounded message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: hold every deletion fence and keep live idempotency records

An owner now holds one admission fence per concurrent bulk deletion instead
of one at a time, so admission reopens only when the last deletion finishes
regardless of completion order. Expired fences are pruned as new ones arrive
and the set is bounded, so an abandoned fence cannot accumulate or lock an
account out.

A failed durable claim write is no longer read as an absent record. Handing a
terminal result over without recording its claimant would let another
invocation collect the same one-shot output once the database recovered, so
the collection reports the retryable unavailable path and leaves the result
for a later poll.

Control invocation records now evict tasks the store no longer holds before
live ones, over a bounded scan. Dropping a live task's record would let a
caller retry apply its queue, steer, or interrupt a second time once the
transport replay had also expired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: keep the deletion fence portable and never drop a live record

The admission fence is written with plain update operators again. DocumentDB
rejects pipeline-form updates, and this runs before any deletion, so the
pipeline form would have failed both bulk-delete endpoints outright on a
supported database target.

An excess deletion is now refused rather than silently displacing the oldest
active fence, which would have reopened admission for a deletion still
running. Expired fences are pruned before the cap is tested, so only genuinely
concurrent deletions count against it.

Control invocation records now sweep every settled task's entry when the
window fills, and a window of entirely live records refuses the new control
before touching the child instead of evicting one. Applying a command with no
room to record it would let the caller's own retry apply it twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: hold the fence, bound recovered results, and expire stale commands

The admission fence is renewed for as long as its deletion runs, so a very
large account or a stalled database cannot let it lapse while conversations
are still being removed. Only the deletion's own fence is renewed, and the
renewal stops with the operation.

Cancellation now covers every conversation the cascade removed, not only the
ones a plan named: a grandchild lives in its own parent's scope, which a plan
naming the deleted root never reaches.

A routed request carries the deadline its caller waits for, and an owner drops
one that arrives past it. A publisher disconnected mid-request queues the
envelope offline and delivers it after the caller was told the owner was
unavailable, which would otherwise steer a child the caller believes untouched.

A result recovered from its durable child message is bounded like a routed one.
The message keeps the child's untruncated output, so recovery could otherwise
return far more than the routed result limit allows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: size the fence window so a renewal can be observed

The renewal test set a 30ms drain timeout but the five-minute grace window
dominates it, so the interval was 100 seconds and no renewal could fire
inside the test's deletion. The grace window is an option now, matching the
store's other timings, and the test sizes the window to 90ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: wire the durable claim method and close the fence follow-ons

The production store never received `claimSubagentTaskResult`, so every
terminal result would have surfaced as unavailable once a task settled. The
host wires that object from JavaScript, where the factory's parameter type
checks nothing, so the factory now refuses a store missing any method it
calls rather than failing at the first claim.

The routing transport takes a dedicated publisher with the offline queue
disabled. The shared client held commands issued during a disconnect and
delivered them after the caller had given up, which the request deadline
narrowed but could not close inside the clock-skew allowance.

Fence renewal invalidates the cached auth document like the fence and release
paths, and a renewal reporting its entry gone re-takes the fence instead of
letting the deletion run on unfenced. The post-delete cancellation retries a
transiently unreachable owner: the conversations are already gone, so it is
the only pass that can still stop a late-admitted child.

A replaced replay entry no longer leaves its bytes counted, which would have
inflated the cache's total until unrelated responses were evicted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: wait on observed lease renewal instead of a fixed delay

The shared-lease renewal test held a 60ms lease and slept 100ms before
asserting an overlapping worker was refused, so a loaded runner that
starved the 10ms heartbeat past the TTL let the lease lapse and the
second worker run. Spy on acquisition and renewal, then wait until a
renewal succeeds past the acquired lease's own deadline — direct
evidence the heartbeat carried it past expiry, with no timing
assumption — and give the lease enough headroom that a stalled timer
no longer decides the outcome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): close the routing, fence, and cache gaps found in review

Five separate seams, each with its own failure:

`Cluster.duplicate` reads its first argument as a startup-node list and
its second as the overrides, unlike `Redis.duplicate`, so the publisher's
`enableOfflineQueue: false` was silently dropped under
`USE_REDIS_CLUSTER` and a command issued mid-disconnect could still
reach a child after its caller was told `unavailable`. Route both
through `duplicateIoRedisClient`.

The control window's capacity refusal ran before the store knew whether
it owned the task, so unrelated local load could veto a cancellation
bound for another replica. Establish that the task is local first and
leave a remote one to its owner's window.

`clearInterval` stops only future fence renewals. One already waiting on
the database could resolve after the release, read its own lifted fence
as expiry, and write a replacement that nothing remained to lift —
closing subagent admission for the account until it aged out. Track the
in-flight renewal, refuse overlapping passes, and await it before
releasing.

Every owner bounds its own task list, but the aggregation appended each
batch whole, so the model-facing list grew with the number of replicas
holding the scope. Cap the merged list while still reading every reply
for the stale-registration sweep.

The admission-fence prune commits independently of the fence that
follows it, so a refused or failed push left the cached auth document
describing entries the collection no longer held. Invalidate whichever
way the second write goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): cap the merged task list the poll tool actually reads

Each owner bounds its own reply and the remote aggregation bounds their
sum, but `listTasks` merged that bounded remote list with however many
children this replica owns and returned it whole. `check_background_task`
could therefore still receive roughly twice the advertised cap. Bound the
deduplicated, sorted result and export the cap so both seams share one
number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: admit every task the merged-list cap test starts

The base store admits ten concurrent runs per scope by default, so
starting 150 at once left most refused for capacity and the assertion
never reached the merge it was written to check. Raise the cap for this
store only; admission is a different invariant with its own tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): let a deletion notice its admission fence lapsing

Renewal failures were logged and swallowed, so a run of rejected writes
let the last confirmed `fencedUntil` pass while the deletion carried on
believing admission was still closed — long enough for another replica
to admit a child against conversations about to be removed. Track the
deadline only a confirmed write advances, and check it after the drain,
before anything is deleted: nothing has been removed at that point, so
the operation fails closed and the caller retries once the fence can be
held. A lapse detected after the rows are gone is logged instead, since
reporting failure there would invite a retry against conversations that
no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: raise both concurrency caps the merged-list test trips

Raising the per-scope limit left the store-wide `maxRunningTotal` at its
default hundred, so fifty of the hundred and fifty starts were still
refused. Verified against the base store directly this time: with only
the per-scope cap raised it admits a hundred, and with both raised it
admits all hundred and fifty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): close the fence renewal gap and keep running tasks listed

A renewal that started before its deadline but landed after it was still
credited with extending the fence from its own start time, so a window in
which admission stood open was papered over: a child could take a lease
the drain had already read past and the deletion would proceed without
cancelling it. The deadline now only advances when the write lands while
the previous one still holds; anything later records a lapse the fence
cannot be restored backwards over.

The model-facing cap sorted oldest-first and sliced, which dropped the
newest tasks — including children that had only just started running,
and which the poll tool offers no other way to discover. Bound by status
instead: running children first, then the most recent settled results.
Both caps share one helper, and the routed aggregation now bounds after
its loop so the choice is made across every owner's reply rather than by
whichever answered first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): finish the cap and the fence at the seams they still missed

The status-aware cap only reached the requester: an owner's own reply
still sliced positionally, so a replica holding more than the cap dropped
its running children before the requester could bound anything. Both
sides now share `boundedTaskList`.

A fence that lapsed during the deletion itself was only logged. The rows
are gone by then, so failing is still wrong, but the child another
replica admitted while the fence was down is not: the fence is retaken
and the drain repeated to cancel it.

A child's lease renewal had the same retroactive hole the admission fence
had — Mongo filters on the `now` captured before the call, so a write
landing after the lease expired still moves the row forward, while an
owner drain reading active leases in that gap saw the thread as free. The
lease now carries its own deadline and a late renewal stops the executor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: cover the lease lapse and the post-deletion re-drain

The owner-side cap shipped with a regression test; these two did not.
One drives a lease renewal that succeeds only after the lease it was
extending had expired and asserts the executor stops; the other lets the
fence lapse during the deletion itself and asserts a second drain runs
while the request still reports success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): close live-task lifecycle gaps

* test(redis): exercise cluster node discovery

* fix(test): type cluster discovery seam

* fix(ci): wait for orphaned apt processes

* fix(ci): reserve time for apt drain

* fix(ci): skip optional fonts in MCP jobs

* fix(agents): recover tasks after owner loss

* fix(agents): preserve local task discovery

* fix(agents): initialize fail-fast cluster publisher

* style(agents): sort routing test imports

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 02:16:35 -04:00
Danny Avila
e4d6bb71f9
📁 feat: Surface Stateful Workspace Downloads (#14984)
* feat: surface stateful workspace downloads

* fix: sort workspace change imports

* fix: reuse workspace button primitives

* fix: hide collapsed workspace actions
2026-08-18 22:05:13 -04:00
Dustin Healy
a33b128c47
🪪 fix: Preserve Stored Access Token Expiry Over ID Token Exp (#14982)
* 🪪 fix: Preserve Stored Access Token Expiry Over ID Token Exp

extractOpenIDTokenInfo let the ID token exp claim overwrite the token set's stored expires_at. The ID token is minted at login and never refreshed, so once a session outlives the ID token TTL, isOpenIDTokenValid reports the access token as expired even when expires_at is hours in the future, and OpenID placeholder substitution silently stops: MCP headers configured with {{LIBRECHAT_OPENID_ACCESS_TOKEN}} ship the literal placeholder string as the bearer credential and the receiving server rejects every connection with an unparseable JWT until the user fully logs out and back in.

The ID token exp now only fills a missing expiresAt instead of overriding a stored one. Identity claim enrichment from the ID token is unchanged, and the exp fallback for token sets without expires_at is preserved.

* 🪪 fix: Validate ID Token Expiry Before ID Token Placeholder Substitution

The precedence fix made isOpenIDTokenValid track only the access token expiry, so an MCP header using {{LIBRECHAT_OPENID_ID_TOKEN}} could substitute an ID token that had already expired. The ID token exp is now preserved separately as idTokenExpiresAt and checked at the ID token substitution site, so an expired ID token substitutes empty rather than a stale credential while access token substitution is unaffected.

* 🪪 fix: Address OpenID Expiry Review Round

Fix expires_at at the source in the OpenID JWT strategy. The stored value described the
incoming bearer's exp even when access_token came from the session or a cookie, so it could
describe a different credential entirely. A new decodeJwtExpiry helper reads the exp of the
token actually stored, and payload.exp is kept only when the raw bearer is the resolved
access token. Opaque session or cookie tokens now store no expiry rather than a wrong one.

Apply a 30 second clock skew buffer in isOpenIDTokenValid and isIdTokenCurrent via a new
exported OPENID_EXPIRY_BUFFER_SECONDS, mirroring OPENID_REUSE_EXPIRY_BUFFER_SECONDS in
AuthController. Tokens that would expire in transit are treated as already expired.

Make isIdTokenCurrent fail closed when idTokenExpiresAt is absent. exp is REQUIRED in an ID
token, so a missing value means the token is malformed or the claims parse threw. The check
uses == null so an exp of 0 counts as present and therefore expired.

Read the ID token exp with a numeric type check so an exp of 0 records idTokenExpiresAt and
fails closed downstream while a non-numeric exp is ignored, and compare the stored expiry
with != null so a gap filled expiry of 0 reads as expired instead of as no expiry at all.

Raise an actionable re authentication error for the ID token placeholder instead of
substituting an empty string. An empty substitution produced a malformed Authorization
header and a 400 downstream rather than a clean signal that the user must re authenticate.

Raise the same re authentication error from processSingleValue when a user has an OpenID
identity, the stored token set is no longer valid, and the value still contains a credential
bearing OpenID placeholder, so the expired access token case that motivated this PR signals
re auth instead of silently shipping or stripping the placeholder. Only the access token, ID
token, and generic token names raise: identity metadata resolves from the user document and
an expiry hint never needed a token, so those keep their existing literal then strip
behaviour. Unknown placeholder names also stay literal and diagnosable, matching the
existing resolvable placeholder policy.

Add the comments the review asked for on the exp fallback heuristic, the EXPIRES_AT
placeholder semantics, why stale ID token claims stay usable for identity fields, and the
advisory nature of the freshness check.

* 🪪 fix: Honour Opaque Access Tokens And Type The OpenID Re-Auth Error

Drop the ID token exp fallback in extractOpenIDTokenInfo. Storing the access token expiry
honestly means an opaque access token now records no expiry, and the fallback then handed the
ID token exp authority over a credential it does not describe. A deployment issuing opaque
access tokens alongside a short lived ID token saw isOpenIDTokenValid go false and the
credential guard reject a perfectly good access token, which worked before this branch. An
unknown access token expiry is now treated as no expiry, and the ID token exp only ever gates
ID token substitution through idTokenExpiresAt.

Give the re-authentication signal a type. OpenIDReauthRequiredError is raised at both the ID
token placeholder and the credential placeholder guard, ErrorController maps it to a 401
carrying the actionable message, and the class exposes statusCode so the agent generation
path answers 401 instead of a bare 500 for the same condition.

Omit rather than blank a header whose credential placeholder is still unresolved on a final
resolution pass, since an empty bearer credential is malformed under RFC 6750 while an absent
header lets the upstream answer its own challenge. Identity placeholders keep stripping to an
empty string.

Move the resolvable placeholder docblock onto the pattern it describes, resolve an EXPIRES_AT
of 0 as the string 0 for consistency with the neighbouring null checks, and let
AuthController consume the exported OPENID_EXPIRY_BUFFER_SECONDS so the 30 second skew
allowance has a single definition.
2026-08-18 22:02:33 -04:00
Ravi Kumar L
da0491d5db
💻 fix(agents): require Code Interpreter for programmatic MCP tools (#14977)
* fix(agents): require code interpreter for programmatic MCP tools

* test(data-provider): fix tool options fixture type

* fix(agents): address programmatic tool review feedback

* fix(agents): avoid no-op update on version revert
2026-08-18 17:16:58 +02:00
Ravi Kumar L
006e421cd2
💡 feat: add DB-backed admin insights (#14898)
* feat: add Mongo-backed admin insights

* feat: gate insights with environment variable

* fix: tighten insights access and activity metrics

* fix: preserve insights date selections

* perf: parallelize insights search aggregation

* test: wait for MCP conflict recovery

* test: satisfy strict MCP recovery typing

* fix: disable insights pagination while loading

* fix: localize insights range shortcuts

* fix: bound insights search input
2026-08-18 07:51:51 -04:00
Danny Avila
547bd8c4bf
🧵 feat: Persist View-Only Subagent Threads (#14957) 2026-08-18 07:41:42 -04:00
Danny Avila
a47ba7168f
🪢 feat: Custom Request Headers For Langfuse (#14945)
*  feat: Custom Request Headers For Langfuse

Self-hosted Langfuse behind an authenticating proxy or gateway could not
be reached: every outbound Langfuse request hardcoded `Authorization` and
nothing else. Adds `langfuse.headers`, mirroring `endpoints.custom`
headers, and applies it to all four request surfaces — trace/media export
(via the agents run config), feedback scores, central project-identity
lookup, and admin credential verification.

Values resolve through the same pipeline as endpoint headers, so
`${ENV_VAR}` interpolation and header-safe encoding come along.
`extractEnvVariable` continues to refuse infrastructure secrets, so a
config cannot exfiltrate `MONGO_URI` through a header. A header whose
variable is unset is dropped with a one-time warning rather than sent as
a literal `${...}`, which a gateway would read as a wrong credential
instead of a missing one.

Headers merge beneath LibreChat's own `Authorization` on the REST
surfaces, matching `mergeHeaders`, so a custom header can never displace
the Langfuse credential.

These are deployment-level and documented as such: trace export batches
spans from every user through a single exporter, so unlike endpoint
headers they cannot carry per-user placeholders.

The central project-id cache key now includes the headers, so the
header-less module warm-up cannot record a proxy rejection against the
entry the request path later reads.

* 🔒 fix: Keep Langfuse Headers Out Of Stored Overrides

The generic admin config API accepts any field path inside an allowed
section, so `langfuse.headers` could be written through it. Unlike
`langfuse.secretKey`, headers are a map rather than one scalar path, so
the config secret registry cannot encrypt them at rest or mask them on
read — an admin-written map would sit in Mongo in plaintext and come
back in plaintext, widening exposure of what are gateway credentials.

Rejects them on both the dotted-patch and object-upsert routes, the same
way process-backed MCP servers are held to librechat.yaml. This is what
makes "deployment-level" true rather than merely documented.

* 🐛 fix: Wire Config Middleware And Header Collisions For Langfuse

Two codex review findings.

P1 — `api/server/routes/admin/langfuse.js` never mounted
`configMiddleware`, so `req.config` was undefined in production and
credential verification silently ran without the deployment's proxy
headers: exactly the deployments this feature targets could not save a
connection. The handler unit tests injected `config` into their mock
requests, so they stayed green. Mounts the middleware after the access
checks (unauthorized callers still short-circuit first) and adds
route-level tests that assert the handler actually receives a resolved
config — the composition root, not the component.

P2 — spreading custom headers under `Authorization` only replaced an
exact-case collision. A configured `authorization` survived alongside
the managed `Authorization` and fetch appends rather than replaces,
sending both credentials in one combined value. All four request sites
now use `mergeHeaders`, which already merges case-insensitively with the
override winning; tests cover the lower- and upper-case variants.

* 🔒 fix: Mask Langfuse Headers On Read And Harden Value Handling

Three codex round-2 findings.

P1 — the write guard blocked storing `langfuse.headers` in Mongo but did
nothing for the read path: `GET /api/admin/config/base` serves the
resolved AppConfig through `redactConfigSecrets`, which only knows
registered scalar secrets, so a yaml-configured gateway credential was
returned in full to any admin with Langfuse read access. Adds a
secret-map registry that masks values while keeping key names, so an
admin can still see which headers a deployment sets. Masking is safe
precisely because these are yaml-only — a masked read cannot be
round-tripped back over the real values. A malformed non-object value at
that path is dropped rather than serialized.

P2 — `mergeHeaders` indexes one spelling per lowercase name, so a config
holding both `authorization` and `AUTHORIZATION` had only one displaced;
the survivor was then appended by `Headers` into a combined value. Case
variants are now collapsed at resolution, before any consumer sees them.

P2 — `resolveHeaders` encodes only values it substitutes a user field
into, and no user is supplied here, so a literal or interpolated
character above U+00FF reached `Headers` unencoded and threw. Final
values now go through `encodeHeaderValue`; Latin-1 still passes verbatim.

* 🔒 fix: Keep Langfuse Header Credentials Out Of Logs And Validate Names

Three codex round-3 findings, plus a documented boundary for the fourth.

P1 — `loadCustomConfig` logs the parsed config at startup (`printConfig`
defaults true), so a literal gateway credential in `langfuse.headers` was
copied into application logs on every boot, undoing the masking the admin
read path had just gained. The printed copy now goes through
`redactConfigSecretMaps`, reusing the same registry. Scoped to map-valued
secrets so scalar-secret log behavior is unchanged; the live config keeps
its real values.

P2 — a nonempty but invalid field name (` X-Token`, `X Proxy Token`)
passed the emptiness check and then threw in the `Headers` constructor,
which would break export, verification, lookup, and feedback for the whole
deployment rather than that one header. Names are trimmed and validated
against the RFC 7230 token grammar, and dropped with a warning otherwise.

P2 — unresolved `${VAR}` detection tested the *resolved* value, so a
credential legitimately containing `${...}` was mistaken for a failed
substitution and dropped. Detection now inspects the configured text and
checks the referenced variables directly, which also drops references to
denylisted infrastructure secrets instead of forwarding them verbatim.

The fourth (fanout gateway forwards only `Authorization`, so a tenant
Langfuse behind its own proxy is not covered) is a real limitation in a
separate component. Documented on the schema field and in the example
config rather than left implied.

* 🔒 fix: Scope Langfuse Headers To Configured Origins

Three codex round-4 findings.

P1 — one header map was attached to every destination a run resolves to.
Under fanout that means a credential meant for an internal gateway was
also sent to the central destination, typically Langfuse Cloud: an
unrelated third-party origin. Headers are now attached only when the
destination's origin is one the deployment explicitly configured (a
self-hosted base URL, the fanout collector, or a tenant destination set
by env). The built-in `*.cloud.langfuse.com` defaults are excluded
precisely because nobody pointed at them. For trace export this also
means attaching after the export branch settles on a `baseUrl` rather
than before, since which destination wins depends on the branch.

P2 — `encodeHeaderValue` only encodes above U+00FF, so a newline, CR, or
NUL passed through and threw in `Headers`, breaking every request rather
than the one header. Values are trimmed (the common trailing-newline
case) then validated against the legal field-value bytes; an embedded
CRLF is a request-splitting attempt and is dropped, not stripped.

P2 — the write guard matched only the exact `headers` property, so
`{ langfuse: { "headers.X-Token": "..." } }` and root-level dotted
variants slipped through into the Mixed overrides document, where the
nested-map redactor never walks them and a later read returns them in
plaintext. All dotted spellings are now rejected.

* 🔒 fix: Bind Langfuse Headers To One Configured Origin

Four codex round-5 findings.

P1 — the round-4 allowlist still authorized every configured origin, so a
deployment with both a collector and an explicit central host sent the
same credential to both. `langfuse.headers` is one map with no way to say
which endpoint it authenticates to, so it is only unambiguous when the
deployment configures exactly one Langfuse origin. Iterating on which
origins to guess was the wrong axis; headers are now sent only when there
is a single configured origin and the destination is it, with a warning
when several make the intent unresolvable. That covers the self-hosted
case this feature exists for; multi-destination deployments need
per-destination headers the schema cannot yet express.

P1 — `fetch` defaults to following redirects, and Node strips
`Authorization` across origins but keeps arbitrary headers, so a redirect
off an allowed origin would hand the gateway credential to a host that
passed no check. Requests carrying custom headers now refuse redirects;
requests without them keep the default, so nothing changes for existing
deployments.

P2 — `extractEnvVariable`'s whole-string branch is anchored and greedy, so
`${CLIENT_ID}:${CLIENT_SECRET}` parsed as one variable name and the raw
template was sent as the credential. References are expanded here now, so
only literal values reach that path.

P2 — a valid token name is not necessarily usable: `Transfer-Encoding`
makes `fetch` throw and a fixed `Content-Length` misdescribes the body of
every other request sharing the map. Request-framing names are dropped.

* 🐛 fix: Expand Langfuse Header References Exactly Once

Codex round 6 (P2). After expanding `${VAR}` references myself I still
handed the result to `resolveHeaders`, which runs `extractEnvVariable`
over it again — so a credential containing `${PATH}`, or any other name
that happens to be set, was silently rewritten on export, verification,
lookup, and feedback. The round-3 test only used an *unset* embedded
name, which the second pass leaves alone, so it could not catch this.

Resolution no longer round-trips through `resolveHeaders`. The only part
still wanted from it was stripping `{{...}}` user placeholders, which is
now applied directly; expansion, encoding, and validation were already
local. Adds a test whose embedded variable is set, which fails against
the previous pipeline.

* 🐛 fix: Process Langfuse Header Templates Before Substitution

Codex round 7 (P2), the mirror of round 6. Having stopped re-expanding
the resolved credential, the placeholder strip was still running over it:
a token containing `{{LIBRECHAT_USER_ID}}` had that span deleted and
`abc{{...}}ghi` went out as `abcghi`.

Establishes the invariant the last two rounds were circling. Every
template operation — placeholder strip, unresolved-reference check,
expansion — now runs on the operator's configured text, and the
credential is substituted last and never touched again. Gateway
credentials are arbitrary strings, so none of their bytes are syntax.

Also moves the unresolved-reference check after the strip, so it no
longer reports a variable inside a `{{...}}` span that the strip removes.
2026-08-17 20:39:23 -04:00
Danny Avila
7d62be2ad3
🕸️ feat: Run Saved Agent Teams as Subagents (#14944)
* feat: Add graph subagent integration

* style: Sort response usage test imports

* fix: Preserve lazy graph runtime context

* fix: Use isolated graph input helper

* test: Align graph integration fixtures

* fix: Preserve lazy graph runtime capabilities

* fix: Bound lazy graph metadata preload

* fix: Harden lazy graph resolution lifecycle

* fix: Coalesce lazy graph member resolution

* fix: Snapshot initialized graph members only

* fix: Preserve lazy agent runtime context

* fix: Preserve batched lazy context preparation

* fix: Preserve graph member capability bounds

* fix: reconcile graph subagents with execution profiles

* style: align graph subagent types with formatter
2026-08-17 18:02:52 -04:00
Danny Avila
7ee9e4e363
🚏 fix: Preserve Endpoint Routing on HITL Resume Replay (#14948) 2026-08-17 18:02:16 -04:00
Danny Avila
fe4615591b
📦 chore: bump @librechat/agents to v3.6.3 (#14941) 2026-08-17 13:39:59 -04:00
Danny Avila
aa35cd42b1
📬 feat: Add Durable Agent Trigger Delivery (#14925)
* feat: wire trusted agent trigger dispatch

* feat: add durable agent trigger delivery

* fix: annotate trigger envelope byte limit

* test: isolate trigger startup in server specs

* fix: fence trigger delivery during account deletion

* test: isolate trigger service in user controller specs

* fix: close trigger deletion admission race

* fix: harden account deletion fences

* fix: close durable trigger review gaps

* fix: require offline stale-fence recovery

* fix: type trigger lane sequence ids

* fix: fence admin user deletion triggers

* fix: make trigger deletion recovery durable

* fix: harden offline user deletion

* fix: serialize trigger lane publication

* style: sort trigger delivery imports

* fix: recover orphaned trigger publications

* fix: preserve trigger recovery ordering

* fix: fence trigger publication during purge

* fix: defer remote trigger deletion fences

* fix: close durable delivery cleanup races

* fix: drain CLI generation owners before deletion
2026-08-17 09:25:08 -04:00
Danny Avila
f8f118ef29
🛰️ feat: Execute Generic Agent Trigger Deliveries (#14921)
* feat: add generic agent trigger dispatch seam

* refactor: harden trigger dispatch contract

* fix: annotate envelope depth alias

* style: sort trigger envelope imports

* fix: reject unknown trigger dispatch modes

* fix: reject unknown trigger envelope versions

* refactor: validate complete trigger envelopes

* feat: add agent trigger execution host

* fix: enforce trigger delivery contracts

* fix: harden trigger admission path

* fix: finish trigger cancellation handling

* fix: retry strict steer rollout gaps

* fix: retry paused trigger steers

* fix: parallelize trigger admission setup
2026-08-17 08:45:49 -04:00
Danny Avila
57ea1137f6
🛡️ feat: Let Admins Restrict Stateful Workspace Scopes (#14910)
* feat: let admins restrict stateful workspace scopes

* fix: enforce stateful scope policy across agent paths

* fix: close stateful scope policy activation gaps
2026-08-17 01:29:19 -04:00
Danny Avila
485abef3fa
🥚 refactor: Default Agents to Preferred Stateful Workspace Scope (#14908)
* feat: add user default for stateful agent workspaces

* style: sort stateful workspace imports
2026-08-16 22:31:01 -04:00
Danny Avila
f829aca9fb
🧩 fix: Align Tenant and MCP Configuration Resolution (#14904)
* fix: Align Tenant and MCP Configuration Resolution

* fix: Preserve Operator-Owned MCP Entries

* fix: Preserve Configuration Source Ownership

* style: Normalize Middleware Import Order

* fix: Preserve Process Server Precedence

* test: Align Tenant-Aware E2E Setup
2026-08-16 22:30:46 -04:00
Marco Beretta
fdc9c77f6e
🗄️ feat: Archive All Chats From Data Controls (#14885)
* feat: archive all chats from data controls

Adds an "Archive all chats" row under Data controls > Your data, next to
Shared links, with a confirmation dialog. It calls a new
POST /api/convos/archive/all endpoint backed by archiveAllConvos, which
archives every conversation currently visible to the user in a single
updateMany and refreshes the stats of every chat project the archived
conversations belonged to.

Temporary and retention-expired conversations are skipped: they are
already hidden from the chat list, so archiving them would only surface
them in the archived view. The update runs with timestamps disabled so
each conversation keeps its own updatedAt and the archived list stays
sorted by real activity.

Archiving a conversation now also drops the new-chat message cache alias
for it. A chat's first turn writes the same message array under both the
conversation key and the new-chat key, so without this the messages of a
just-archived chat kept rendering on the new chat screen until a reload.
Deleting already handled this; archiving did not.

* fix: keep archive-all state consistent

* fix: drop stale detail caches after bulk archive

* fix: harden archive-all request handling

* fix: reconcile archive batch failures

* Fix project stats refresh races and archive route boundary

* Fix archive-all review findings

* Fix archive scan index and partial-batch stats refresh

Reconcile project stats for already-committed archive batches when a later
batch fails, and index the archive scan as { user, _id } so non-tenant
pagination can use _id order.

* Fix Recoil reset after a partial archive-all failure

Refetch the submitted conversation on error and start a new chat only
when that conversation is still active and already archived.

* Fix archive recovery from resetting a newly opened chat

Re-read the active Recoil conversation after the archive-state lookup
resolves, so a slow getConversationById cannot start a new chat if the
user already opened another conversation.

* Fix project-stat reconciliation after archive races

Keep retrying optimistic project-stat writes instead of returning a
stale document after three lost CAS attempts, and retry destination
project discovery after a transient distinct failure.

* Fix archive reset and project-count increment races

Leave already-archived chats open after archive-all, recount new
project conversations instead of incrementing, and skip a delayed
increment when a concurrent refresh already recorded that chat.

* Recover destination projects after discovery retries exhaust

Keep committed conversation IDs when post-archive distinct fails, then
rediscover those projects in finally so a moved conversation's
destination still gets reconciled after the error is rethrown.

* Fix archive-all recovery batching and remount pending state

Recover destination projects in 500-id chunks so the final lookup
cannot exceed Mongo's command size, and share archive-all pending
state through a mutation key so Settings remounts stay disabled.

* Stamp bulk-archived chats and refresh the pinned cache

Bulk archive wrote only isArchived, so the archived table dated every
swept chat by createdAt and the default archivedAt sort dropped the whole
run into the legacy null group. Stamp one timestamp for the sweep; the
filter only matches unarchived chats, so an existing stamp cannot move,
and timestamps: false still preserves each updatedAt.

The pinned section fetches on its own key with a five-minute stale time,
so an archived pin kept rendering in the sidebar until that expired.
Invalidate it alongside the other lists on both success and failure.

Also drop the async from the failing-batch updateMany mock: its
Promise<never> is not assignable to the Query return type, while a plain
synchronous throw types as never.

* Bound archive recovery state with the sweep marker

Recovery held every committed conversation id for the life of the
request so the finally block could re-run project discovery after an
in-loop distinct gave up. Slicing that array into 500-id queries capped
the BSON command size but not the heap, so a very large history could
exhaust a worker mid-archive.

The archivedAt stamp already identifies exactly what this call
committed, so recovery is now one distinct scoped to it. That filter is
a prefix of the existing user/isArchived/archivedAt index, and the two
discovery call sites collapse into one filter-taking helper.

* Reconcile archive stats when a write outcome is unknown

A batch that commits but whose result never returns, a stepdown or a
connection drop between commit and acknowledgement, left archivedCount
at zero, so the finally block skipped both marker recovery and the stats
refresh. The chats were archived, so no retry could find them again: the
sweep filter no longer matches them and their projects kept stale
counts.

Both now key off the write attempt rather than the returned count.
Nothing else needs to change, because the marker is stamped by the same
write whose result went missing.

* Retry dropped project refreshes and guard stale pointer writes

Two ways a project could keep stale stats after archive-all.

A refresh that rejected was logged and dropped for good. Its chats are
archived, so no retry of archive-all can find them again to recompute
against, and the likeliest rejection is the recoverable one:
refreshChatProjectStatsForUser gives up when the project changed under
every compare-and-set attempt. Failures are now collected and replayed
once the rest of the run has stopped competing with them.

A save already in flight could also undo the sweep. Its conversation
document still said visible, so its tail took the pointer branch and
wrote lastConversationId back to a chat the sweep had just archived,
leaving the project advertising activity on a chat the workspace hides.
The pointer write now confirms the chat is still visible first, and
recomputes the project when it is not.

* Verify project pointers after the write, not before

Checking visibility before the pointer write only moved the race earlier:
a sweep landing between the check and the update still archived the chat
and cleared the project, and the write then restored it as
lastConversationId.

The check now runs after the write and repairs instead of preventing. A
sweep that lands earlier is caught here; one that lands later refreshes
the project itself, and refreshChatProjectStatsForUser compare-and-sets,
so it cannot commit a count it read before this write. Same single
indexed read as the check it replaces.
2026-08-16 22:11:57 -04:00
Danny Avila
c519f26904
📦 chore: bump @librechat/agents to v3.6.2 (#14905) 2026-08-16 19:55:02 -04:00
Danny Avila
27ed491a2a
🏷️ fix: Persist the Ephemeral Agent's Display Label as Sender (#14899)
* 🏷️ feat: Add getEphemeralSender and Cover the Ephemeral-Id Format

* ♻️ refactor: Consolidate the Ephemeral Sender Chains

* 🏷️ fix: Decode the Ephemeral Sender for Persisted Messages

* 🏷️ fix: Mirror the Persisted Sender Chain in useGetSender

*  test: Widen the Custom-Endpoint Fixture Type

*  test: Expect the Spec Label in the Composer Placeholder

* 🏷️ fix: Resolve the Sender from Exact Labels, Not the Lossy Id
2026-08-16 19:50:30 -04:00
Danny Avila
1b7e2a4e6a
perf: Optimize First Load of Large Conversations (#14901)
*  perf: Index the Conversation Fetch and Trim the Client Message Projection

*  perf: Memoize the Message Tree per Cache Write

*  perf: Serve Message Reads via the Trimmed Projection and an Ownership Probe

*  perf: Defer Collapsed Disclosure Bodies Until First Expansion

*  perf: Progressively Mount Long Threads from the Scroll Anchor

* 🩹 fix: Address Codex Findings on Retention, Anchoring, and Cache Bounds

* 🩹 fix: Poll the Oversized Export Precondition Through the Progressive Mount

* 🩹 fix: Keep Video Results in the Client Message Projection
2026-08-16 19:45:21 -04:00
Danny Avila
7d850c308a
🧠 feat: Add Live Reasoning Labels (#14893)
* feat: add live reasoning labels

* fix: Stabilize reasoning label checks

* fix: Address reasoning label review findings

* chore: Bump Agents SDK for reasoning labels

* fix: Reset reused reasoning step evidence

* fix: Reconcile cleared reasoning labels

* fix: Fence reasoning label resets

* fix: Reset reasoning ownership before gap labels

* fix: Preserve THINK type through label reset

* test: Expect run-global reasoning revision
2026-08-16 18:11:55 -04:00
Marco Beretta
832bac39ad
🗄️ feat: Record When a Conversation Was Archived (#14863)
* feat: record when a conversation was archived

The archived chats dialog has a "Date Archived" column that was bound to
createdAt, so it showed when the chat was created rather than when it was
filed away. Nothing recorded the latter.

Conversations now carry archivedAt, set on archive and cleared on
unarchive, and the column reads it. Chats archived before the field
existed have no stamp and fall back to createdAt, which is exactly what
that column already showed for them.

The archive view sorts on the new field. archivedAt is absent on every
previously archived chat, so the missing-value group is the common case
here rather than an edge case: the cursor's null handling, written for
titles, now covers both, and an absent stamp survives the cursor as null
instead of collapsing to the epoch and replaying the whole archive.

* fix: address review findings on the archived-at stamp

- Protect `archivedAt` from saveMessageToDatabase's unset sweep. Any
  persisted field missing from endpointOptions is unset, so sending a
  message in an archived chat cleared the stamp while leaving isArchived
  true, silently dropping it into the legacy fallback group.
- Order the legacy group by the createdAt the dialog displays rather than
  by last activity. The cursor's secondary key is now chosen per sort
  field, so the fallback the cell renders and the order the server
  returns cannot disagree.
- Put that secondary key in the archive index too, so paging the legacy
  group does not fall back to a blocking sort.

* fix: keep archivedAt on a redundant archive request

Opening an archived chat and hitting the archive shortcut, or retrying
the POST, sent isArchived: true again and replaced Date Archived with
now. saveConvo now stamps only on the unarchived-to-archived transition
and still clears the field on unarchive.

* fix: make archive timestamp updates atomic

* test: type the archive race spy against the driver signature

* fix: archive without an aggregation-pipeline update

DocumentDB documents no support for pipeline-form updates on any engine version,
and the repository's compatibility assessment records that a prior P0 rewrote the
three that existed. Stamping archivedAt through a $cond pipeline reintroduced one,
which would have sent every archive and unarchive to the route's 500 handler on a
supported 5.0 deployment.

The conditional stamp is now a compare-and-set on isArchived, which keeps the
transition atomic without a pipeline: only the write that finds the chat unarchived
stamps it, so a duplicate or retried archive leaves the original date alone and an
unarchive that lands first is re-stamped. Schema defaults and createdAt-on-insert
go back to mongoose's own setDefaultsOnInsert and $setOnInsert, and tenantId is
once again stripped by the tenant-isolation plugin rather than by hand.

* fix: do not report a racing archive as a missing chat

Both conditional writes of the compare-and-set miss when the archive flag flips
between them: the chat was already archived when the transition write ran and
unarchived again before the already-archived write. saveConvo returned null for
a conversation that plainly exists, so POST /api/convos/archive answered 404.

Confirm the conversation is really gone before accepting that result, and retry
the pair when it is not. An unknown id still costs one existence read and falls
straight through to the 404.

* fix: resolve a fully contended archive to the chat's real state

Alternating archive and unarchive requests can split every attempt of the
compare-and-set: each transition write sees the chat archived and each
already-archived write sees it unarchived. Exhausting the retries therefore
proved nothing about whether the conversation exists, and the no-upsert archive
route turned a lost race back into a 404.

Read the conversation once more when the retries run out and answer with its
actual current state instead.
2026-08-16 17:07:44 -04:00
Marco Beretta
7857a99d63
perf: Flip the Pinned Flag Without a Full Conversation Save (#14862)
Pinning routed through saveConvo, which reads every message id for the
conversation and writes the whole array back just to set one boolean, and
can trigger a project-stats recompute on top.

None of that applies to a pin: it moves no chat between projects, changes
nothing the project workspace hides, and opens no retention window. A
dedicated setConvoPinned does the single findOneAndUpdate instead.

Measured against an in-memory MongoDB with the real message methods
wired in, on a 120-message chat: two driver commands and 3706 bytes
before, one command and 245 bytes after. The write scales with the
message count, so the gap widens on longer chats.

Archiving keeps using saveConvo, which it needs for exactly the project
stats and retention work a pin does not.
2026-08-16 17:07:27 -04:00
Marco Beretta
5fc05ac037
🕰️ fix: Stop Pinning and Archiving From Counting as Chat Activity (#14861)
* fix: stop pinning and archiving from counting as chat activity

Both routes went through saveConvo, which lets mongoose stamp updatedAt.
The sidebar orders chats by that field, so pinning hoisted an untouched
chat to the top of Today, and unarchiving a year-old chat dropped it
there too instead of back into its own date group.

saveConvo now takes preserveUpdatedAt, and both routes pass it. They also
pass noUpsert: with timestamps suppressed an upsert would insert a
conversation carrying neither createdAt nor updatedAt, so an unknown
conversation id is now a 404 rather than a silently created stub.

* test: pin a project's activity pointer against metadata-only saves

Review raised that preserving updatedAt could drag a project's
lastConversationAt back to the pinned chat's older timestamp, since the
incremental path $sets it outright.

That path is not reachable here: a pin carries no chatProjectId, so
previousChatProjectId stays null while the conversation has a real one,
projectMembershipChanged is therefore true, and saveConvo takes the full
recompute branch instead. This test holds that in place, with a newer
sibling conversation in the project so a regression to the incremental
path would fail it.

* fix: keep updatedAt through the retention backfill

Under RetentionMode.ALL a legacy chat with no stored isTemporary gets a
second write after the main update, and that one still had mongoose
timestamps enabled. The first archive of such a chat therefore bumped
updatedAt anyway and landed in Today, defeating preserveUpdatedAt on
exactly the old conversations it was meant to protect.
2026-08-16 16:20:20 -04:00
Danny Avila
fb8ae881cf
⏱️ feat: Show Run-Step Durations On Tool Cards (#14892)
* ⏱️ feat: Show Run-Step Durations On Tool Cards

Surfaces how long each tool call took, derived from the `closed_at` /
`created_at` pair already carried by `on_run_step_closed` — the same event
#14871 and #14873 use for the terminal status. No new event, no new SDK
surface.

The duration is stamped onto the content part at the same three sites as
`runStepStatus`, so it survives a reload and a resumable reconnect rather
than living only on the live React message:

- `callbacks.js`, on the aggregated part before the event is forwarded
- `RedisJobStore`, in the host-authored replay reconstruction branch
- `useStepHandler`, on the live message

Rendering lands in the shared `ProgressText`, which nine tool cards already
use, rather than in each card: one place decides whether a duration is shown
and how it reads, and the cards only forward the number. That keeps this from
adding a tenth independent state derivation to a component family whose
label/announcement/progress split is already the subject of AI-1810.

The value is deliberately absent rather than zero whenever it would be a
guess — no `created_at`, non-finite input, or a negative elapsed time from
two clocks that disagree, which is now reachable because a step can be opened
in one process and closed in another after a checkpoint resume. Sub-second
durations are suppressed as noise, and it renders only on a settled,
non-error card, where the slot is not already carrying the cancelled icon or
the error suffix.

For assistive technology the compact form (`3.5s`) is hidden and paired with
a spoken equivalent ("took 3.5 seconds"), both inside the button, so the
accessible name carries the duration without an `aria-live` region
re-announcing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🎨 style: Sort Imports In Touched Files

The import-sort gate runs against the files a PR changes, so pre-existing
drift in `ProgressText.tsx` and `RedisJobStore.ts` surfaced on this branch.
Both were already unsorted on `dev`; this is the sorter's output, with no
semantic change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🐛 fix: Accept Partial Timestamps In Run-Step Duration Helper

`getReportableRunStepDurationMs` declared its parameter as
`Pick<RunStepClosedEvent, 'created_at' | 'closed_at'>`, where `closed_at` is
required. That contradicted the function's own purpose: every guard inside it
exists precisely to handle stamps that may be missing.

The Redis replay branch reconstructs closures from persisted JSON and holds
nothing stronger than "might be a number", so it failed to typecheck against
the narrower signature.

Widened to an exported `RunStepTimestamps` shape with both stamps optional,
rather than asserting at the call site — an assertion would move the decision
about what is trustworthy somewhere it cannot be enforced, which is the thing
the helper exists to centralize. Callers holding a fully-typed event still
pass, since a required field satisfies an optional one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🐛 fix: Suppress Duration When Failure Arrives As errorSuffix Alone

At every call site `error` carries cancellation while failure travels
through `errorSuffix` with `error` false, so gating the duration on
`!error` alone rendered "· 3.5s" beside "· failed" — and announced it.
The gate now checks both terminal-failure channels.

The original test pinned only the `error: true` path, which is why this
survived; the failed-via-suffix path is now pinned separately, both the
visible and the announced half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🧩 refactor: Persist Raw Run-Step Durations, Threshold At Render Only

The three stamp sites filtered through the 1-second reportability
threshold before persisting, baking a presentation rule into stored
data: a 900ms step stored nothing, making "fast" indistinguishable from
"not derivable" and unrecoverable if the display rule ever changes.

Stamp sites now persist the raw `getRunStepDurationMs` value — absent
only when genuinely not derivable — and the renderer alone decides what
is worth showing, which `ProgressText` already did. Rendering is
unchanged. `getReportableRunStepDurationMs` is removed; it existed only
to serve the write-time filter, and a test now pins that sub-threshold
durations survive to storage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🐛 fix: Suppress Duration On Backgrounded Bash And Code Cards

A backgrounded call's run step closes when dispatch returns the handle,
so the stamped duration is the dispatch time. Rendering it beside
"Running/Finished in background" misstated a detached task's runtime as
seconds — and violated the "settled card only" rule, since the card is
still tracking the detached run. Scope is exactly the two cards that
parse background handles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🌍 fix: Format The Sub-10s Decimal For The Active Locale

The fractional seconds value was interpolated as a raw JS number, which
hardcodes the en-US decimal point into every language — "1.4s" where
the locale writes "1,4 s" — and translators cannot fix a number
formatted in code. The value is now formatted via Intl.NumberFormat
with i18n.language, following MessageTimestamp's pattern of threading
the language into the util; plural-key selection stays on the numeric
value. A malformed language tag falls back to the plain number.

Also documents the two accepted limits of the derivation, so they read
as decisions rather than oversights: positive clock skew is
undetectable from a single stamp pair, and the value is wall-clock
elapsed, so a step held open across a suspension (checkpoint resume,
HITL approval wait) includes that time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🐛 fix: Persist A Durable `backgrounded` Marker Through Harvest; Localize Minute Digits

Codex round 3, both findings confirmed.

**Background origin survived only as transient state.** The dispatch
handle in `tool_call.output` and the live status-marker attachment are
both gone once the harvester patches the settled task's stdout over the
handle — so the round-2 suppression (`backgroundHandle == null`) came
back on after harvest or reload, showing dispatch time as the task's
runtime. Following the same rule as e4bd15d (persist facts, decide at
render): the harvest patch now stamps `backgrounded: true` onto the
tool call in the same atomic write that erases the handle — on the heal
path too, which re-applies over full-row saves that reverted the part.
The cards gate on handle-or-marker; the dispatch duration itself stays
stored.

**Minute-branch digits bypassed locale formatting.** The seconds branch
went through Intl.NumberFormat while minutes interpolated raw numbers,
so Arabic/Persian locales flipped to ASCII digits above one minute. All
interpolated values now flow through the (renamed) formatDurationValue;
an ar-EG test pins the localized digits.

data-schemas cannot be installed in this environment (same npm ci 403 as
packages/api), so message.ts/harvest.ts are syntax-checked with
resolution off and otherwise verified by review; CI runs their real
typecheck and suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🧪 test: Assert The `markBackgrounded` Stamp In Harvest Expectations

The successful-harvest test's exact `toHaveBeenCalledWith` object did
not include the newly forwarded `markBackgrounded`, so the API suite
would fail on it. All three harvest-call expectations now assert
`markBackgrounded: true` — the exact-object one of necessity, the two
`objectContaining` ones deliberately, since the durable stamp (on the
best-effort file-failure path and the reapply heal alike) is now part
of the behavior under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🎨 style: Wrap Harvest Spec Expectation Per Prettier

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 16:17:02 -04:00
Marco Beretta
8a946290f6
📌 fix: Fetch Pinned Chats Independently of the Chats List (#14860)
* feat: give the pinned chats section its own fetch

The sidebar's pinned section filtered pinned chats out of the paginated
chats list, which only holds the 25 most recently updated conversations.
Once 25 newer chats existed, a reload hid the pin until the list was
scrolled far enough to fetch the page it lived on.

Pins are now fetched directly via GET /api/convos?pinned=true behind a
dedicated query, so every pin paints with the sidebar regardless of where
it falls in the chats list. Pin and unpin invalidate that query, and the
shared conversation cache helpers keep it in step so a rename, delete or
archive is reflected without waiting for a refetch.

Pins stay out of the date groups, which groupConversationsByDate already
handled.

* fix: address review findings on the pinned chats section

- Drain the cursor rather than capping the pinned request at 100. Since
  pins are kept out of the chats date groups, anything this query dropped
  was invisible in the sidebar entirely, not merely further down a list.
- Apply the active bookmark filter to the pinned request and key its
  cache by it, matching the chats list beside it.
- Move a pin to the top of the section when the caller asks for it, so a
  pin that just received a message leads the way it does in the chats
  list instead of waiting for a refetch.
- Invalidate the pinned list when a conversation is unarchived, since
  archiving removes it from that cache and nothing put it back.
- Index the pinned lookup: it filters on user + pinned and sorts by
  updatedAt, which no existing compound index covered.
- Protect `pinned` from saveMessageToDatabase's unset sweep. Any
  persisted field missing from endpointOptions is unset, so sending a
  message in a pinned chat silently unpinned it.

* fix: keep the pinned cache reconciled across the other convo mutations

Second review pass on the independent pinned query.

- Fall back to the pins already loaded in the chats pages when the
  dedicated request fails. Pins are stripped from the date groups, so an
  error otherwise emptied the section and hid them everywhere.
- Restore default focus and reconnect refetching, matching the
  conversations query. A pin changed in another tab is only reconciled by
  a refetch, since that tab's mutation never touched this cache.
- Invalidate the pinned list from the mutations that can produce or alter
  a pinned chat without going through pin itself: duplicate, fork,
  import, project assignment, and shared-link deletion.

* fix: invalidate pins on tag and project-deletion changes

Third review pass, same class as the last: the pinned query is keyed by
the active bookmark filter, so changing a chat's tags can move it in or
out of that filtered set, and deleting a project unsets chatProjectId on
its chats, pinned ones included.

* fix: cancel in-flight pinned fetches when deleting a conversation

Deletion cancelled the regular and archived queries but not the pinned
one, so a pinned GET issued before the delete could resolve after the row
was stripped and write the deleted conversation back, leaving a row that
navigates to a missing chat. Restoring default focus and reconnect
refetching in the previous commit made those in-flight fetches more
likely, so this widened rather than appeared.

Cancelled on mutate, and invalidated on success since cancelling a race
is best effort.

* test: make the SSE query-cache mock key-aware

The conversation cache helpers now run a second, pinned-keyed findAll
pass. This mock ignored its key argument and always returned an
allConversations entry, so those pinned writes were attributed to
allConversations and the write-count assertions saw three instead of two.

* fix: keep pins in sync through upsert and pin-only pages

Root-level SSE updates and resumable settlement call upsert rather than
update, so the independently cached pinned row never moved or refreshed.
An all-pin first page also left the chats virtual list empty, so
onRowsRendered never asked for the next cursor.

* fix: keep pins current through SSE recovery and project delete

Resumable SSE reconciliation invalidated conversation and allConversations
only, so an independently cached pin kept stale title and order.
Deleting a project-backed pin that lived only in that cache also skipped
the project query, because the mutation never read chatProjectId there.

* fix: keep pins current after bookmark edits and failed pages

Renaming or deleting a bookmark rewrote tags on conversations but left
the tag-keyed pinned cache pointing at the old filter. An all-pin page
whose next fetch failed also retried forever because the empty-list
effect had no memory of the attempt. Unpinning a pin that only lived in
the dedicated cache removed it from Pinned without inserting it into
Chats, and later cursor pages cannot recover a row whose updatedAt just
jumped ahead of the current cursor.

* fix: keep pins visible after a failed refetch

A failed pinned refetch left React Query holding the previous list, so
the nullish fallback never ran and a newly pinned chat vanished from
both sections. Unpinning an older pin also inserted it into every
cached chats variant, including bookmark and search results it would
not match. Drop the checked-in agent task prompt.

* test: type the pinned conversation fixtures correctly

The delete mutation takes a plain string conversationId, but reading it
back off a TConversation fixture widens it to string | null. Hoist the id
into its own constant so the call site passes the real string.

Type the tag fixture as TConversationTag so it carries the required _id
and user fields the mocked resolved value expects.

* style: sort the sidebar imports to the repo order

The new pinned-section imports went in out of the longest-to-shortest
order the import sorter enforces.

* fix: keep drained pins and empty chat caches from breaking the sidebar

A pinned page failing partway through the drain rejected the whole query, so
every pin already fetched was discarded and the section fell back to whatever
the chats cache happened to hold. Publish the accumulated pins before
rethrowing so the retry renders against the partial set.

Unpinning a chat that only lives in the pinned cache reinserted it into the
chats list by spreading the first page, which is absent once removal has
filtered out the last loaded row. Rebuild that page instead, matching the
upsert path.

* fix: order fallback pins by their timestamp

The merge kept dedicated rows in Map insertion order and appended the pins
recovered from the chats cache after them. A chat pinned while the dedicated
refetch is failing is the newest pin, so the server would return it first, yet
it landed last and could sit below the section's visible 30vh. Sort the merged
set newest-first so a fallback row takes the place the server would give it.

* fix: keep the shared badge and the move-to-top order on pins

The pin response has no isShared: the flag is derived per list request by
attachSharedFlags, which only runs for the list queries. Reinserting an unpinned
chat into Chats therefore dropped its shared-link badge, because unlike an
in-place update there is no existing row to carry the flag over from. Read it off
the cached pin before the update removes that row.

The chats cache refreshes updatedAt when it moves a conversation to the top, but
the pinned cache only reordered, leaving the previous turn's timestamp on the row.
Sorting the section newest-first then put it straight back. Refresh the timestamp
there too, so the move survives the sort and both caches agree.
2026-08-16 11:28:33 -04:00
Danny Avila
06bf324cf0
🛤️ feat: Per-Agent Code Execution Routing With Stateful Session Scopes (#14848)
* feat: route code execution per agent profile

* chore: sort execution profile imports

* test: preserve stateful environment literal types

* fix: isolate stateful code environments by user

* fix: preserve per-agent code routing end to end

* fix: route code priming by execution profile

* fix: isolate code profile lifecycle state

* fix: preserve mixed-profile code resources

* fix: complete stateful skill routing
2026-08-16 09:42:15 -04:00
Danny Avila
d411512a98
⬆️ chore: Bump @librechat/agents to v3.6.0 (#14890)
* ⬆️ chore: Bump `@librechat/agents` to v3.6.0

Bumps the pin in `api` and `packages/api` from `^3.5.1` to `^3.6.0`. The
caret on `^3.5.1` cannot cross the minor, so both manifests and the lockfile
need the explicit bump.

v3.6.0 contains three changes over v3.5.1, all additive:

- `fix: Close Subagent Child-Graph Run Steps` — subagent child graphs run via
  `workflow.invoke()` outside `Run.processStream`, so the terminal sweep never
  reached their steps. They now close on both the success and error paths,
  which is what makes `on_run_step_closed` reliable for subagent tool cards.
- `fix: Restore Run Steps Across Process Resumes` — open run-step lifecycle
  state is now persisted in LangGraph checkpoints, so a step opened by one
  process closes correctly after a resume on another.
- `feat: route code execution per agent profile` — new optional
  `codeSessionKey` partition for code-session ids and file refs.

No breaking changes: every new field on the public type surface is optional,
and the package's own dependency set is unchanged between the two versions
(verified against the registry), so the lockfile diff is limited to the
`@librechat/agents` entry itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🔒 chore: Sync `bun.lock` with the agents v3.6.0 bump

`bun.lock` still recorded both workspace requirements and the resolved
package as `@librechat/agents@3.5.1`, which no longer satisfies `^3.6.0`, so
`bun install --frozen-lockfile` would reject the committed state.

`bun install --lockfile-only` cannot run in this environment: bun stores no
integrity for the `xlsx` URL dependency and therefore re-fetches
`cdn.sheetjs.com`, which the sandbox network policy denies (403 on CONNECT).
The entry was updated directly instead, which is exact here because the
package's dependency graph does not move between the two versions: its
`dependencies`, `peerDependencies` and `optionalPeers` at 3.6.0 are identical
to 3.5.1 (checked against the registry), so only the version, the resolution
id and the integrity hash change. The integrity matches the one npm resolved
into `package-lock.json`, and the two existing
`@librechat/agents/*` hoisting overrides stay valid because the dependency
set they resolve is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 09:15:35 -04:00
Danny Avila
1d789c41a5
🧩 fix: Normalize MCP UI Resource Rendering (#14868)
* fix: normalize MCP UI resource rendering

* fix: filter unsupported MCP UI resources

* fix: preserve MCP UI marker examples

* fix: handle MCP UI resource edge cases

* fix: harden MCP UI marker sanitization

* fix: scope MCP UI marker sanitization

* fix: parse MCP UI marker contexts

* fix: align MCP UI sanitizer parsing

* fix: match MCP UI renderer syntax

* fix: align blockquote marker spans

* fix: decode MCP UI text node sources

* fix: sanitize nested subagent markers

* fix: bound MCP UI sanitizer traversal

* fix: keep MCP UI marker mapping linear

* style: sort security patch imports

* fix: harden nested MCP UI sanitization

* fix: mirror citation cleanup for MCP UI markers

* fix: clean decoded citation markers

* fix: clean assembled citation markers

* fix: align MCP marker sanitization with rendering

* fix: match persisted MCP marker render paths

* fix: preserve highlighted citation boundaries

* fix: align MCP markers across content renderers

* fix: preserve citation renderer boundaries

* fix: match legacy thinking trim semantics
2026-08-15 12:49:59 -04:00
Danny Avila
eb3b353712
📡 fix: Publish App-Level MCP Tool Catalogs Without a Reserved Revision (#14858)
* 📡 fix: Publish App-Level MCP Tool Catalogs Without a Reserved Revision

Shared MCP servers advertised no tools to agents, so every turn failed with
"configured to use MCP tools, but none are available" (#14857).

`replaceAppServerTools` returned false whenever a publication carried no
`publicationRevision`, but only `refreshChangedTools` reserves one. Every other
app-level publisher — the first-connect snapshot, reinitialization, on-demand
catalog reads, the retained-catalog restore — was silently dropped. The agent
path fails closed on that drop: the skipped write returns null, so reinitialize
yields no tools and the turn 503s.

Startup hid it. `connectAppServers()` defers the initial refresh and calls
`refreshToolList()` itself, which does reserve, so a boot that reaches its MCP
servers looks healthy. Only a lazily created app connection — the server not yet
up when LibreChat boots, a dropped connection, a cold cache — takes the
unreserved path.

`ConnectionsRepository` now reserves before its own `tools/list`, matching the
list_changed path; a failed reservation publishes unordered rather than failing
the connection. Publishers with no pre-fetch reservation point have already
fetched by the time they reach the cache, so they take the next revision at write
time instead of being discarded. `mergeAppTools` still publishes at revision 0 and
stays deferential to a live catalog.

* 📡 fix: Bind App Catalog Ordering to the Fetch That Produced It

Addresses review feedback on the previous commit: allocating a revision at
publish time lets a slow `tools/list` of an old catalog outrank a newer one that
reserved after it started, and it would let the retained-catalog restore — which
republishes deliberately pre-mutation data — outrank a live catalog.

Ordering now travels with the data. `fetchToolsSnapshot` reserves before its
first page and returns the ticket on the snapshot, so every app-level publisher
reads the revision belonging to the read it is publishing rather than one
allocated at an unrelated moment. `fetchOrderedToolsSnapshot` carries the
refresh's revision when it defers to one, since that is whose catalog it returns.

With the reservation at the single point where app-level tools are read, no
publisher can forget it, so `replaceAppServerTools` goes back to refusing an
unordered write: a publication that lost its ticket fetched at an unknown time
and cannot be ordered.

A failed reservation is reported as `orderingUnavailable` rather than swallowed,
which keeps the list_changed path retrying instead of publishing a catalog that
would be silently dropped, and leaves inspection unaffected by a transient cache
outage.

`MCPServerInspector.getToolFunctions` becomes `getToolCatalog` and returns the
revision with the tools, so there is no variant that quietly discards ordering.

* 📡 fix: Retry an Empty App Catalog That Could Not Reserve Ordering

Review follow-up. The no-tools-capability branch destructured the reservation
result and dropped `orderingUnavailable`, publishing without a revision when the
revision store was transiently unavailable. That write is rejected in silence,
and unlike the snapshot branch this one returned without reaching
`refreshToolList()`, so whatever the server last advertised stayed in place until
the connection was recreated or the cache expired.

Both branches now route an unreservable catalog through the same retry path.

* 📡 fix: Serve Tools Whose Shared Catalog Write Could Not Be Ordered

Review follow-up. Only the shared catalog write needs ordering; the tools
themselves were just read from the server and are correct to serve. Discarding
them because the write could not be ordered is what turns a cache failure into a
server that appears to have no tools at all, which is the reported symptom.

`updateMCPServerTools` now returns the tools it built when the publication has no
reserved revision, instead of null. A superseded write still discards — there
another replica holds something newer.

Reinitialization also asks the connection to republish under backoff when its
snapshot could not reserve ordering, so the shared catalog does not stay cold
until something else triggers a refresh.

* 📡 fix: Surface a Discarded App Catalog Instead of Debug-Logging It

#14857 went a release without a diagnostic because the only trace of a dropped
app-level catalog was a debug line no deployment runs. Operators saw agents fail
every turn with nothing in the logs to explain it, and the reporter had to read
the source to find the cause.

A publication discarded because it cannot be addressed or ordered means this
server's tools are unavailable to every agent that selected them, and serving an
unpublished catalog means every request re-fetches it. Both are warnings now. A
superseded write stays at debug: concurrent replicas produce it routinely and the
winner already holds newer tools.

Tests pin the level, so a later refactor cannot quietly make the failure silent
again.

* 🧪 test: Pin the Reinitialize Path's Catalog Ordering

Reinitialization is the path an agent falls back to when the shared catalog is
cold, so it is where #14857 surfaced as "configured to use MCP tools, but none
are available". Nothing pinned that it forwards the ordering its snapshot was
fetched with, nor that it asks the connection to republish a catalog it could
not order.

Both assertions fail against the pre-fix source.
2026-08-15 12:48:23 -04:00
Anubhav Anand
a2ad0aa0c8
🤐 feat: Allow Promptless Sends When Files Are Attached (#13717)
*  feat: Allow sending file attachments without a text message

When an agent asks the user to upload a document, the user could attach
the file but still had to type a placeholder message ("OK", "Here is
the file") before the send button enabled and the submit guard let the
message through.

Attachments now count as submittable content:

- New isSubmittableMessage(text, fileCount) util: non-whitespace text
  OR at least one attached file.
- ask() in useChatFunctions uses it instead of bailing on empty text,
  so an empty draft with attached files submits.
- SendButton receives the attached file count and enables accordingly.
- ChatForm only marks the text field as required when no files are
  attached, so react-hook-form validation no longer blocks handleSubmit.

Submitting an empty draft with no attachments is still rejected at all
three layers.

Fixes #13646

* Address review: support replayed file-only turns + drop empty vision text

- ask(): count replayed attachments (overrideFiles) in the submittable
  check and skip it entirely for regenerate, so a file-only message can
  be regenerated or saved-and-resubmitted instead of being rejected as
  empty.
- formatVisionMessage(): omit the text content part when the message text
  is empty. Anthropic rejects empty text content blocks with HTTP 400,
  and an empty block adds nothing for other providers; image-only sends
  now format cleanly. Added formatMessages tests for with-text and
  image-only (Anthropic + other) cases.

* Address review: keep attachment-only turns valid for providers, answer mode, and titles

- formatMessage: substitute minimal text when a user turn carries files but no
  inline content, so Anthropic does not reject an empty user message for RAG or
  code-environment attachments.
- assistants chatV1: send the same stand-in for attachment-only Threads
  messages, which reject an empty body. The persisted message keeps empty text.
- ChatForm: attachments no longer make an empty draft submittable in answer
  mode, where submitText consumes the click without answering or sending.
- agents request: seed title generation from attachment filenames when the turn
  has no text, so immediate-mode titles are not invented from an empty string.
- useChatFunctions.regenerate.spec: mock the utils barrel over the real module
  so new exports resolve.

* Cover the agents path for attachment-only turns

AgentClient formats its payload with the SDK's formatMessage, not the local
one, so the earlier guard missed the endpoint the feature actually targets: an
attachment-only turn still reached Anthropic as an empty user message. Apply
the same stand-in after the file-context and quote merges, so a turn that
already gained inline content is untouched.

* Carry filenames on freshly attached files

The fresh-file submission mapping copied only file_id, filepath, type, and
dimensions, so the attachment-only title fallback read an undefined filename
and produced nothing. Include filename, and cover it with a test that submits
an empty draft with one attachment.

* Address review: cover assistants v2, fresh agent attachments, editor, and title fallback

- agents client: the current turn has no files during buildMessages, so read
  the resolved attachments from message_file_map instead. The previous guard
  only ever fired for persisted historical turns.
- assistants chatV2: the default assistants endpoint routes here, so it needs
  the same stand-in body chatV1 got.
- assistants title: fall back to filenames, then the response, and keep the
  default title rather than saving an empty one.
- EditMessage: retained attachments make an empty edit submittable, matching
  the composer, so the overrideFiles replay path is reachable from the UI.

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
2026-08-15 12:47:31 -04:00
Danny Avila
88747f0ad8
🩺 fix: Render Stopped Run Steps From Explicit Status (#14871)
* 🩺 fix: Render Stopped Run Steps From Explicit Status

Tool calls decided "still running" vs "stopped" with a whole-message
heuristic:

  const cancelled = !isSubmitting && progress < 1 && !hasError;

That inference cannot tell which step actually stopped. An aborted step
keeps spinning while `isSubmitting` is still true, and when submitting
ends, every unfinished part flips to "Cancelled" at once regardless of
which one died.

`@librechat/agents` v3.4.6+ emits `on_run_step_closed`, a terminal
per-step signal carrying `status` and timestamps — including for steps
swept at end-of-run because the caller aborted. The pinned 3.5.1 already
ships it; nothing consumed it.

- `StepEvents.ON_RUN_STEP_CLOSED` plus `RunStepClosedEvent` /
  `RunStepStatus` types mirroring the SDK payload.
- `PartMetadata.runStepStatus` — a dedicated field, since `status` is
  already claimed by activity-label and question-form parts.
- Server handler forwards the event without the visibility gating the
  other step handlers apply: a step whose open reached the client must
  get its close, or the client is left inferring again.
- `useStepHandler` writes the terminal status onto the tool call part.
- Both decision points (`ToolCall`, the shared `useToolCallState`) prefer
  explicit status, keeping the heuristic as fallback for messages saved
  before this and endpoints that do not emit the event.

Threaded through the five cards sharing `useToolCallState`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🩹 fix: Address Codex Review On Run Step Closure Rendering

- Persist the terminal status server-side. The handler emitted the
  closure without folding it into `contentParts`, so the status existed
  only on the live React message: a reload or resumable reconnect
  dropped it and fell back to the very heuristic this fixes. Now stamped
  onto the aggregated tool-call part (via `stepMap`, falling back to the
  event's own index) before forwarding.

- Honor terminal status independently of output parsing. Gating on
  `hasError` meant a `failed` step with unparseable output rendered as
  "cancelled", while a `failed`/`cancelled` step whose output did parse
  as an error was not terminal at all and shimmered indefinitely when no
  completion event arrived. A closed step now forces progress complete
  and reports `failed` as an error state on its own authority.

- Pass the status to the second `BashCall` branch, which rendered the
  same updated component without it.

- Reuse `Agents.RunStepClosedStatus` in `PartMetadata` instead of
  redeclaring the union, so a future SDK status cannot diverge between
  the event and the persisted part.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

*  fix: Replay Closed Status On Redis Resume, Announce Failures

- Apply closure events during Redis reconstruction. The stamp added in
  the previous commit mutates only the originating process's in-memory
  `contentParts`; a resumable reconnect landing on another replica
  rebuilds from `RedisJobStore.getContentParts`, whose allowlist omits
  `on_run_step_closed`. The status was therefore absent from the sync
  snapshot and, being snapshot-covered, never redelivered as pending —
  so multi-replica resume fell back to the whole-message heuristic.
  Handled as a host-authored event alongside `on_steer_applied` and
  `on_activity_label`, since the SDK aggregator has no notion of it.

- Announce terminal failures in the live region. Forcing terminal
  progress for a closed step meant a `failed` tool reached the
  `aria-live` region through `getFinishedText()`, which only special-
  cased cancellation and otherwise announced "completed function" —
  telling screen-reader users the opposite of what the card showed. A
  regression introduced by the previous commit; error states now
  announce failure before any completion string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🎯 fix: Resolve Closed Steps By ID, Never By Index

The steer and HITL offset wrappers clone and shift only `ON_RUN_STEP`
and `ON_AGENT_UPDATE`; every other event passes through untouched. A
stored `on_run_step_closed` therefore carries the SDK's unshifted index,
while the part it belongs to was rebuilt at the shifted one. Any run
containing a steer insertion or HITL resume would stamp the status onto
an earlier tool card, or none — leaving the real card on the fallback
heuristic while mislabeling a different one.

- Redis reconstruction builds a step ID -> index map from the replayed
  `on_run_step` payloads (which carry the shifted index) and resolves
  closures against it, mirroring what the live callback does via
  `stepMap`.

- The live handler drops its `?? data.index` fallback for the same
  reason. Skipping is the safe failure: a missing status degrades to the
  old heuristic, whereas a misplaced one actively mislabels the wrong
  card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 10:14:16 -04:00
Danny Avila
e1178d3c65
🏘️ fix: Scope OpenID User Cache Keys to Signed User Identity (#14837)
* fix(auth): scope OpenID user cache by tenant

* fix(auth): preserve pre-auth cache scope

* fix(auth): type OpenID reuse secret
2026-08-14 15:51:18 -04:00
Danny Avila
c06fbff475
📦 chore: bump @librechat/agents to v3.5.1 (#14830)
* 📦 chore: bump `@librechat/agents` to v3.5.0

* chore: bump agents sdk to v3.5.1
2026-08-14 11:14:07 -04:00
Ravi Kumar L
bc6392d05b
🪢 fix(langfuse): mark provider-backed agent traces (#14833)
* fix(langfuse): mark provider-backed agent traces

* fix(langfuse): mark stored response traces

* test(langfuse): isolate provider marker setup
2026-08-14 10:28:25 -04:00
Danny Avila
eaef87fa26
🚀 chore: Prepare v0.8.8-rc1 (#14394)
* 🚀 chore: Prepare v0.8.8-rc1 release

* 📚 docs: Complete v0.8.8-rc1 operator references

* 📚 docs: Mark stateful sessions experimental

* 📚 docs: Clarify background code capability

* 📚 docs: Refresh v0.8.8-rc1 operator guidance

* 📚 docs: Highlight v0.8.8-rc1 features in README

* 📦 chore: Bump publishable packages again

* 📚 docs: Add streaming question progress

* 📦 chore: Bump publishable packages again

* 📚 docs: Refresh v0.8.8-rc1 release highlights

* 📦 chore: Bump publishable packages again

* 📚 docs: Refresh v0.8.8-rc1 release guidance

* 📦 chore: Bump publishable packages again

* 📚 docs: Highlight batched Agent questions

* 📦 chore: Bump publishable packages again

* 📦 chore: Bump publishable packages again

* 📦 chore: Bump publishable packages again

* 📦 chore: Refresh v0.8.8-rc1 package versions

* 📦 chore: Refresh v0.8.8-rc1 package versions

* 📦 chore: Refresh v0.8.8-rc1 package versions

* 📄 docs: Note PowerPoint template support

* 📦 chore: Refresh v0.8.8-rc1 package versions

* 📄 docs: Note latest provider and file support
2026-08-14 03:24:59 -04:00
Danny Avila
2f0cd2eb75
🔌 chore: Bump the MCP SDK to 1.30.0 and Parse Content-Type Instead of Searching It (#14820)
`@modelcontextprotocol/sdk@1.30.0` is a small maintenance release on the 1.x line
(upstream's active line is now the 2.0.0 scoped packages). The range was already
`^1.29.0`, so only the lockfile pinned the old version; the manifests move too so
the floor matches what we test against.

Nothing in it is breaking. The four changed type declarations are additive —
optional `maxBufferSize` on `StdioServerParameters`, an optional third
constructor argument on `StdioServerTransport`, optional options on `ReadBuffer`,
optional `keepAliveMs` on the server transport — and the only manifest change is
`@hono/node-server` widening to `^1.19.9 || ^2.0.5`. No new dependencies.

Two behavior changes are worth knowing about even though neither is an API break.
`ReadBuffer` now caps a single stdio message at 10 MB (previously unbounded) and
errors the transport instead of growing, which is reachable through
`StdioClientTransport` if a stdio server returns a very large single result; it
takes `maxBufferSize` if that ever needs raising. And Content-Type handling
switched from substring search to parsed media types, client and server.

Most of the release is Streamable HTTP server hardening we do not run — a 15s SSE
keep-alive, `X-Accel-Buffering: no` on SSE responses, guards so a stale stream's
cancel cannot tear down its successor, and `_closed` checks so a transport closing
mid-request stops registering streams into swept maps. None of it changes how we
behave as a client. In particular it does not address the stale-stream 409 in
#14816: that keep-alive runs in whichever server we connect to, not here.

The same substring-vs-parse mistake the SDK corrected exists in our streamable
HTTP response guard, which classified a response as SSE with
`contentType.includes('text/event-stream')`. A `Content-Type` naming the SSE type
in a parameter — `text/plain; boundary=text/event-stream` — is not an event
stream, but matched. The guard then took `canEmitFallbackSSEError`, so an
oversized body was answered with a synthetic SSE error frame the caller reads as
a well-formed response body, rather than the throw a non-SSE response gets. The
check now compares the parsed media type, via a `mediaTypeEssence` helper added
to the header utils where `mergeHeaders` already lives.

Verified against 1.30.0 rather than assuming: the package was staged into the
worktree's own `node_modules` so it shadowed the shared install, and
`packages/api` `src/mcp` ran green on it — same four pre-existing red suites as
on 1.29.0 (`MCPReinitRecovery` plus three Redis `cache_integration` suites that
need a live Redis), no new failures.
2026-08-14 01:12:56 -04:00
Danny Avila
24d111fde9
feat: Add Gemini 3.7 Flash Support (#14818)
*  feat: Add Gemini 3.7 Flash Support

Adds first-class support for Google's Gemini 3.7 Flash (`gemini-3.7-flash`)
for both the Gemini API (AI Studio) and Google Cloud Gemini Enterprise Agent
Platform, following the Gemini 3.6 Flash integration (#14369).

- 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 handler so it inherits the existing
  strip of deprecated sampling params (temperature/topP/topK), rejected
  penalty params, and thinkingBudget, and defaults to `medium` thinking.
- Generalize that handler's enumerated table from a [id, level] tuple to a
  rule object, so a model can also declare thinking levels it rejects. Gemini
  3.7 Flash errors on `minimal` (which the Google endpoint offers in its
  thinkingLevel slider), so an explicit `minimal` is substituted with the
  nearest supported level, `low`. Explicit low/medium/high pass through
  unchanged.
- Apply Google's introductory pricing ($0.75 in / $3.75 out / $0.075 cached,
  per 1M) to Gemini 3.7 Flash and correct Gemini 3.6 Flash to the same rates.
  Both revert to $1.50 / $7.50 / $0.15 on 2027-01-01; noted at both call sites.

Resolves #14802

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

* 📝 docs: Match the House Style for Promotional Rate Comments

Align the Gemini 3.6/3.7 Flash introductory-pricing notes with the existing
Sonnet 5 convention in the same file: one comment per group, naming the models
and the exact values to restore, so the manual follow-up is unambiguous.

No rate changes.

* ⬆️ chore: Bump `@librechat/agents` to 3.4.7 for Gemini 3.7 Flash Prefill

Unblocks this PR. `NO_PREFILL_GEMINI_MODELS` is model-enumerated in the agents
SDK, so 3.4.6 does not know `gemini-3.7-flash` forbids a trailing `model`-role
turn — editing an assistant reply and resubmitting would reach Google as a
prefill and return HTTP 400 on a model this PR adds to the default list.

3.4.7 (danny-avila/agents#412, released via #413) adds it. Verified the
published tarball: `3.4.6...3.4.7` touches only
`dist/{cjs,esm}/llm/google/utils/common.*` — the prefill array and its comment.
`dist/types` is byte-identical, so there is no API surface change.

Raises the declared range in both workspaces alongside the lock. `^3.4.6`
already permitted 3.4.7, but the fix is required rather than merely compatible,
so the floor should say so.
2026-08-14 01:12:42 -04:00
Danny Avila
5e464bc930
📎 fix: Alias Shell Script MIME Variants to application/x-sh (#14817)
* 📎 fix: Alias Shell Script MIME Variants to `application/x-sh`

Chrome on Linux reports `.sh` files as `application/x-shellscript`
(freedesktop shared-mime-info) and libmagic reports `text/x-shellscript`.
Neither string appears anywhere in the source, so uploads were rejected
even though `application/x-sh` is in the default allowlist and
`codeTypeMapping` maps `sh` to it — `inferMimeType` only consults the
extension map when the client sends no type at all, so a non-empty
browser value passed straight through to the allowlist check.

Alias both variants to the canonical `application/x-sh`, matching the
existing treatment of `text/x-markdown` and `application/x-zip-compressed`.

Also attach `statusCode`/`body` to multer file-filter rejections. Without
them the error misses the `isCustomError` branch in `ErrorController` and
falls through to a bare `500 An unknown error occurred.`, so the rejection
reason was logged server-side but never reached the client. The upload
hook already surfaces `error.response.data.message`, so a rejected file
now explains itself instead of showing a generic upload failure.

* 🔁 refactor: Move Upload Error Contract Into `packages/api`

Addresses codex P1 on #14817.

The producer of the `statusCode`/`body` pair now sits beside its consumer:
`isCustomError` and `ErrorController` are already in
`packages/api/src/middleware/error.ts`, and `CustomError` is already in
`packages/api/src/types/error.ts` — only the construction of that pair was
stranded in legacy JS. `createCustomError` is exported from the same module
as the guard that recognizes it, and `multer.js` is back to a thin caller.

Also pins the `.sh` back-compat claim with tests: configs from the
documented workarounds (`application/x-sh` per #4660/#5689/#6297, and the
broad patterns from #14804) still accept a `.sh` upload after the alias
rewrites the type. A negative control confirms the endpoint config is
genuinely in play rather than falling back to the default allowlist.
2026-08-14 01:12:23 -04:00
Danny Avila
abc669ab58
🩹 fix: Restore the @librechat/api Build and Remove Legacy Code (#14808)
* 🧹 chore: Remove Dead Legacy Agent Controller

`_LegacyAgentController` has been unreachable since the resumable path became
the only route: it is unreferenced, unexported, and untested. It had also
drifted out of compilability against the live file — line 2009 called
`attachConversationCreatedAt(req, { userId, conversationId, isNewConvo })`
against the 3-argument signature declared at line 97, which would await
`undefined` and then throw dereferencing `resolved.createdAt`.

Keeping it was not free. It carried a third independent copy of the response
message-id wiring (`getReqData`, `onStart`, four `updateMetadata` calls), so
every change to how a generation identifies its response row had a dead third
site to keep in step, and no test to say whether it had been kept in step.

Removing the block leaves `createCloseHandler` and the `sendEvent`,
`clientRegistry`, `requestDataMap` and `handleAbortError` imports with no
remaining callers, so those go too. `AgentController` was a three-line
passthrough to `ResumableAgentController`; the real controller is now exported
directly, which also matches the `[ResumableAgentController]` prefix every log
line in the file already uses. `server/routes/agents/chat.js` binds the export
to its own local name and passes the same five arguments, so the route is
unchanged.

No behavior change: 379 lines removed, 2 added.

* 🩹 fix: Remove Duplicated Anchor Block Breaking the `@librechat/api` Build

`dev` does not build. `packages/api/src/agents/activityPhases/runtime.ts`
carries two byte-identical 98-line copies of the same block (former lines
516-613 and 614-711), so rolldown fails to parse it:

    [PARSE_ERROR] Identifier `AnchorFields` has already been declared

The duplicated block is the anchor-construction work from #14805:
`AnchorFields`, `laterDefinedIndex`, `foldedAgentIds`, `boundedAnchor` and
`mergeAnchors`. #14807 was squashed from a branch that predated #14805 and
re-included that commit, so both copies landed. Only the `type` produced an
error — the four function declarations simply redeclare.

This removes the first copy. The two blocks were verified byte-identical
before the cut, and the resulting file has no duplicate top-level
declarations, is missing nothing that #14805 introduced, and retains
everything new to #14807 (`ResolvedPosition`, `resolvePosition`).

Verified: `tsdown` builds, `tsc --noEmit` clean, `config/circular-deps.mjs`
green across all five graphs (it was reporting `✗ @librechat/api` purely
because the build it shells out to was failing), and the 68 tests in
`activityPhases/runtime.spec.ts` pass.

Carried here rather than in a separate PR because this PR's checks cannot go
green until it lands: the failed `packages/api` build cascades into e2e, MCP
list_changed, bombadil and the Docker image jobs.
2026-08-13 19:57:32 -04:00
Marco Beretta
d920328bfa
💬 style: Unify Message Row Layout and Edit Surfaces (#14770)
* style: Unify message row layout and edit surfaces

Route chat, share, and search messages through a shared MessageRow so
user turns render as right-aligned bubbles and assistant turns keep a
visible identity column.

Replace per-part text editors with one edit surface that keeps tools,
errors, and artifacts visible. Preserve non-text fields when saving
content parts, copy the full serialized message, and hide hover actions
that do not apply during streaming or errors.

* style: Align edit footer and lighten editor field in dark mode

Drop the divider above the user edit footer so both edit surfaces share
the same footer treatment.

Move the editor fields to surface-tertiary-alt. Light mode is unchanged
at #fff, while dark mode lifts from #0d0d0d to #2f2f2f so the field sits
above the #212121 panel instead of sinking into near-black.

* style: Drop focus border and ring from message editors

The editor fields changed border color and added a ring on focus. Keep
the border static and rely on the app-level focus handling instead.

* fix: Keep a triggered message action visible when the row is not hovered

Hover actions fade out on non-last rows, and mobile.css only restored
display and visibility for an active button, never opacity. Opening the
fork popover therefore left it anchored to an invisible trigger once the
pointer left the row. Skip the fade entirely while a button is active.

Extract the recipe the three toolbars repeated so the rule has one home.

Rework the streaming guard to the contract the toolbar now implements:
edit and fork are omitted from a streaming response rather than rendered
disabled, and the settled turn above keeps its own actions. It asserted
the removed disabled-and-transparent behaviour and its opacity check only
held because the growing response shifted the row out from under the
pointer.

* style: Trim message edit chrome and stabilize the status row

The edit surface was a titled card sitting inside the conversation: a
bordered panel with an "Edit message" heading wrapping bordered fields,
which read as a settings dialog rather than an inline editor. Drop the
card background, border and heading, and take the footer buttons down to
the small size so the editor reads as a field in the message flow. The
captured row goes from 253px to 187px.

Move "Unsaved changes" into the footer and merge the rerun hint into the
same slot. Both previously added their own row, so typing pushed the rest
of the conversation down. The slot is clamped to two lines, which stays
under the 36px button row, so the footer height holds at 36px regardless
of which message is showing.

* test: Cover message edit layout stability

Add a mock e2e spec that measures the edit footer and section boxes and
asserts they hold steady as the status text appears, for both the
single-part user editor and a multi-part response.

The multi-part case needs an assistant message with two editable parts,
so add an E2E_THINK_REPLY marker to the fake model. Its think tags are
parsed downstream by the agents stream pipeline, which yields a reasoning
part followed by a text part.

* fix: Read the fork popover open state from its store

Fork mirrored the popover state into its own useState and reset it from an
onClose prop. Ariakit 0.4 has no onClose, and React's DOM types accept the
name on any element, so it type-checked, landed on a div and never fired.
Closing by Escape or an outside click therefore left the button reading as
active until the trigger was clicked again.

Read the state from the store instead so every close path clears it.

* fix: Keep the whole toolbar visible while an action is open

Only the triggered button escaped the hover fade, so opening the editor or
the fork popover left the row as a single floating button once the pointer
moved away. Mark the active button and have every action in the toolbar key
off it, so the group stays opaque for as long as a surface is open.

The marker is a dedicated class rather than the existing `active`, which
HoverButtons pins to the edit button of every assistant message and would
hold those toolbars open permanently.

The existing guard pressed Escape to close the editor while focus sat on the
body, so the editor never closed and its assertion only held because the
sibling faded regardless. Close the editor through its own control, and drop
focus before measuring the fade now that Escape returns it to the trigger.

* fix: Withhold copy while a response is still streaming

Text-to-speech, fork and feedback were all withheld from a message that is
still generating, but copy was rendered throughout, so the button offered to
put half a sentence on the clipboard. Gate it on the same condition.

That empties the toolbar for the duration, and SubRow collapses an empty row,
so a streaming response now carries no actions at all until it settles. Both
guards encoded the old contract: the unit test asserted copy was present and
counted a single button, and the browser guard used copy as its proof that the
toolbar had mounted. The settled turn above takes over that role.

* fix: Move retry navigation to the outer edge of a user turn

A user turn is right-aligned, but its sibling navigation rendered ahead of the
actions, so the retry counter sat inboard of the icons instead of under the
edge of the bubble it belongs to. Order it last on user turns.

* fix: Ride the stream instead of chasing it

Following a generating answer went through a helper throttled at 145ms, so the
thread caught up in visible jerks rather than flowing. It now writes the scroll
position directly on each frame, which is what an answer arriving a few pixels
at a time actually needs, and glides only for the one long trip a turn makes,
when sending has to travel from wherever the reader was down to the newest
word.

Whether to follow at all is now answered by where the reader is and which way
they were going, rather than by the abort flag. `useMessageProcess` raises that
flag on any wheel at all, downward ones included, through a throttle whose
trailing call lands after the gesture has ended, so nothing timed to the
gesture could outlive it. Scrolling down to the newest word could therefore
never resume the ride, while the scroll-to-bottom button, which touches no
wheel, always could.

Arrival is judged on the scroll it produces rather than the wheel tick that
started it, because wheel scrolling is animated and at tick time the thread is
still far short of where the tick is taking it. Arriving also counts from
further out than leaving does: while an answer streams the end recedes between
the last tick and the frame that measures it, so judging arrival as tightly as
departure leaves a reader unable to catch it at all.

* fix: Reveal retry navigation on hover while an answer generates

Copy, edit, fork and read-aloud are all withheld from a response that is still
generating, which left the retry counter as the only thing rendering under a
half-written answer. It now reveals on hover there, like the actions it sits
with, and stays put on a settled turn.

* fix: Keep a refused rerun from discarding the edit

While a response is streaming, the edit action stays available on every earlier
row, and those editors see a per-message submitting flag that is false, so
Update and rerun is enabled. The send itself is still refused: ask() returns
false for the duration of the active submission. Both editors ignored that and
closed anyway, so the draft went with them and no rerun ever started.

Both rerun paths now check the result and leave the editor untouched when the
send is refused, so the work survives until the thread is free.

* fix: Let an upward gesture beat the pending send glide

Sending arms a smooth glide down to the newest word, and the landing re-pins the
thread to the bottom. The landing was scheduled two ways, on scrollend and on a
700ms fallback, and neither was ever cancelled. A reader who changed their mind
and headed up mid-flight was pinned again regardless, then dragged back by the
next streaming resize. The fallback fires for the whole window, so this held even
after the glide had visibly settled.

The gesture now marks the glide interrupted, wherever it lets go of the bottom,
and the landing stands down when it sees that. A glide the reader leaves alone
still re-affirms the ride.

* fix: Fade retry navigation on every streaming response format

Every other action is withheld from the row that is still generating, so the
retry counter is the only thing left under a half-written answer. The plain text
row already faded it to hover-only there; the structured rows did not, and left
it sitting on its own.

Both structured paths now apply the same condition, and the class string the
three of them share moves next to the hover action styles it belongs with.

* i18n: Correct the copy the edit surface rewrite left behind

The multi-part hint told the reader to save first and then rerun, but a save
closes the editor and reopening seeds the drafts from what was just saved, so
there is nothing left to rerun and the button stays disabled. Rerunning carries a
single edited section by design, so the hint now states that limit rather than
pointing at a step that is not there.

Drop com_ui_save_submit as well: the per-part editor that used it is gone.

* test: Make the message visual baselines opt-in

The suite asserts sixteen screenshots and the repository tracks none, so
Playwright's default treats every one as a miss and the mock e2e job fails on
Linux. Baselines only compare cleanly against the machine that produced them, and
nothing here can generate ones that match the runner image.

The flows keep running and asserting their structure, which is where their value
was; only the pixel comparison is now gated behind E2E_VISUAL_SNAPSHOTS.

* style: Restore import order in the reworked message files

The repository sorter and CI disagreed with what these files were left holding
after the edit surface rework. No behavior change.

* test: Follow the reworded rerun hint in the edit layout spec

The multi-part hint was restated in the previous commit; this assertion still
expected the old wording and would have failed the mock e2e suite.

* fix: Leave the send glide alone while the answer streams in

Every delta of an answer reruns the scroll effect, and the plain follow writes
scrollTop outright, which cancels an animation on its first frame. So the glide a
send starts was killed by the first token to arrive and the reader was snapped
down instead of carried.

The follow now stands down while a glide is travelling, which is what the hook
already documented but only enforced on the resize path.

* fix: Write a saved edit onto the thread as it stands

An earlier turn stays editable while the newest answer streams, and the save
captured the thread before the request but wrote it back after. Every delta that
landed during the round trip was overwritten. Most of the time the next delta
re-merged and the damage showed as a one-frame truncation, but a save that
resolved after the stream's final write left the cache wrong for the rest of the
session.

The thread is now read once the request has resolved, which is what the content
part editor already did.

The editor actions in this file also wrap again rather than hold one unbreakable
row, for the reason given in the following commit.

* fix: Let the editor actions wrap on a narrow row

At 320px an assistant turn gives the editor about 252px once page padding, the
identity column and the row gap are taken out, and Cancel, Save and Update &
rerun need more than that in English alone. The group was pinned with shrink-0,
so it ran past the edge of the row instead of wrapping. A longer translated label
makes it worse, and the user turn had no margin left either.

Both editors wrap again, which is what the footer did before the status row was
folded into it.

* fix: Catch up to the new bottom when the glide lands

Following stands down for the length of the glide, so an answer that arrives
while it travels moves the bottom past the target the glide aimed at. A short
response that finished before the glide reported landing left the thread a few
lines short of its own end, with nothing left to correct it.

Landing now closes whatever gap opened, unless the reader took over on the way.

* test: Follow the renamed rerun button in the edit flow specs

The button became 'Update & rerun' when the edit surfaces were unified, but two
edit-flow specs still located 'Save & Submit' and would have waited for it until
they timed out. A type comment named the old button too.

* fix: Judge the first thread scroll against a real position

The direction check seeded its last-position ref at 0, so the first scroll
event on an opened thread, which arrives carrying a large positive
scrollTop, read as a jump downward. Near the end that cleared the abort
flag and re-pinned a reader to the stream they were scrolling away from.

Take the first event as a baseline and judge direction from the next.

* fix: Hold the content part editor to what it replaced

EditContentParts took over from EditTextPart and left two of its behaviors
behind.

An emptied box now blocks Save and rerun instead of persisting a blank
part. EditTextPart refused the same edit through its form's required rule
and the sibling EditMessage still does, so both editors hold one line. The
keyboard shortcuts reach the save paths directly, so they are guarded
there too, and the footer says why the buttons are down.

The editor also follows the chat direction again, taking dir and text
alignment from the same setting EditMessage reads.

* fix: Hold the footer height while a response streams

Every action is withheld from the row that is still generating, and a lone
sibling counter renders nothing, so the footer measured zero until the answer
landed and then sprang to the height of the buttons. The transcript stepped
upward under the reader at the moment a response completed.

The placeholder that used to reserve this space went when the footer became
unconditional, so hold the height on the row itself instead.

* fix: Remember where the thread was put before judging a gesture

Direction is judged against the last sample, and the thread is placed at its
end without the reader touching it. With no record of where it was put, their
first gesture was spent taking the baseline instead of being obeyed: a single
PageUp cleared no flag of its own, so the next streamed resize rode the reader
straight back to the end they were leaving.

Every programmatic move now records the position it left the thread at, so the
sentinel stands only until something has actually placed it.

* fix: Spend the start of a turn only once it can be honored

A reader who scrolls away during one answer leaves the abort flag raised, and
nothing lowers it until the next connection opens, which is after this effect
has already seen the send. Marking the turn as started on that first pass spent
it against a closed gate: by the time the flag cleared there was no start left
to honor, the reader was still detached, and the answer they had just asked for
streamed on offscreen.

Record the turn as started only on the pass that acts on it.

* fix: Show the part edits that survived a refused save

The editor saves every changed part through one button, but the endpoint
takes a single part per call and nothing rolls a write back. A part the
server refused therefore left the earlier ones stored while the editor
reported that the message could not be saved, so cancelling from there
walked away from edits that were already live.

Record the writes that landed and reconcile the transcript with them
whichever way the save ended. The refused parts are the only ones left
holding a draft, so a retry no longer rewrites what already arrived.

* fix: Stop a shared transcript from calling the sharer the reader

The share row reused the chat view's user label, which reads "You". It is
the screen-reader heading for the user turn, so anyone opening a share
link heard every prompt the sharer wrote credited to themselves.

Use the neutral "User" label on this surface. It keeps the localization
the row gained, unlike the untranslated string it replaced.

* fix: Let go of the stream when an interaction settles over several resizes

Expanding a tool result mid-answer renders the container first and fills it
once its contents arrive, so one gesture produces more than one resize. Only
the first was credited to the interaction. The second read the reader as still
riding the stream and put them back on the bottom they had just left.

The suppressed resize now settles the ride as well as the near-bottom measure,
using the position the interaction actually left the reader at, so an
interaction that kept them on the end still streams.

* fix: Edit inside a structured text part instead of flattening it

A text content part holds either a string or a { value, annotations } object.
The Assistants thread sync persists the structured form with its file
citations intact, and the editor reads the part through the same union, so
saving an edit wrote a bare string over the whole object and took every
citation with it.

The same object was handed to the tokenizer, which measures length, so a part
that had been edited this way also stored a NaN token count. Write the edit
into value, keep the rest of the part, and count the text itself.

* fix: Keep a saved part's citations in the transcript it is written back to

A text or think part holds either a bare string or a { value, annotations }
object, and the editor already read both through getPartText. Writing the
draft back into the local message cache put the string over the whole value,
so a response carrying file citations lost them the moment it was edited and
did not get them back until a refetch.

Reading and writing now go through the same accessor, so an edit lands in the
shape it was read from and the rest of the part survives.

* fix: Let the message editor follow the chosen font size

Editing a message dropped the draft to a fixed 14px regardless of the
Font Size setting. On dev the textarea carried the markdown class, so it
read --markdown-font-size like the rendered message does; restyling it
into a bordered box replaced that with text-sm, and the new per-part
editor was written the same way. Anyone on Extra Small, Large or Extra
Large saw the text jump the moment they entered edit mode.

Share the .message-content typography with the editors through a
message-editor-text class so a draft is sized like the message it
replaces and keeps tracking the setting.
2026-08-13 19:30:39 -04:00
Danny Avila
df6e15a0de
🔖 feat: Bound Parent Activity Phases With an Exclusive End Index (#14768)
* 🧭 fix: Finalize Parent Activity Phases at Run Completion

* 🧭 fix: Preserve Activity Phase Boundaries

* 🎨 fix: Format Activity Phase Boundary Check

* 🧭 fix: Ignore Late Label Artifacts at Phase Completion

* 🧭 fix: Preserve Logical Activity Phase Membership

* 🩹 fix: Narrow Optional Activity Phase Marker

* fix activity phase tail boundaries

* fix activity phase test lint

* fix straddling activity phase batches

* preserve activity phase boundaries at scale

* fix persisted activity phase final boundary

* fix resumed activity phase edge cases

* fix sparse activity phase grouping

* fix sparse activity phase tail scan

* fix resumed activity phase text fallback

* fix sparse activity phase completion scans

* avoid sparse activity phase runtime scans

* stabilize sparse activity phase resumes

* support activity phases on current ts target

* preserve sparse phase reservations

* finalize activity phase boundary handling

* avoid sparse phase start scans

* fix activity phase final text bounds

* tighten activity phase summary boundaries

* format activity phase boundary checks

* leave final commentary outside activity phases

* recognize lane-tagged final activity text

* rebase retained activity boundaries on resume

* bound activity phase collection work

* correct resumed phase activity count

* resolve late reasoning before phase completion

* preserve lane-tagged final answers

* assert durable activity phase bounds in e2e

* preserve empty finalized activity phases

* ignore empty reasoning at phase completion

* format phase completion guard

* fix(api): retain overflow reasoning anchors

* perf(api): index overflow reasoning anchors

* perf(api): skip empty reasoning index scans

* fix(api): reconcile completion boundaries efficiently
2026-08-12 23:43:35 -04:00
Danny Avila
1a3e2aebcb
🛰️ fix: Attach Request-Scoped MCP Servers (#14780)
* fix: attach request-scoped MCP servers

* fix: satisfy MCP static checks

* fix: format MCP runtime hint
2026-08-12 23:43:02 -04:00
Danny Avila
c44d11ebf4
🧾 test: Pin the Transactions Config Wiring on the Fallback Path (#14779)
Follow-up to #14774. Its tests cover `AgentClient.recordTokenUsage` in
isolation, so the `BaseClient` half of the fix was unpinned: deleting the
`transactions` property from the call site restored the bug with the suite
still green.

These cases drive `sendMessage` with a real app config on `req` and assert the
resolved value reaches `recordTokenUsage` — disabled, the default when no
config is present, and the balance-enabled override that force-enables it.
Each fails if either half of #14774 is reverted.

They also isolate `options.endpoint` for the block. `options` is shared across
this file, and an endpoint left behind by an earlier case routes the
balance-enabled arrangement into `checkBalance`.
2026-08-12 23:42:23 -04:00
Danny Avila
298a3d9ee9
📦 chore: Update @librechat/agents to v3.4.6 (#14781) 2026-08-12 23:42:13 -04:00
James Todaro
e696b07619
🧾 fix: Honor Disabled Transactions on the Token-Count Fallback Path (#14774)
`AgentClient.recordTokenUsage` had no `transactions` parameter, so the setting
never reached `createTransaction`, whose guard reads it from the object it is
handed. `transactions?.enabled === false` saw `undefined` and the write went
ahead.

This path is reached only from `BaseClient`'s fallback branch, when the provider
returns no usable stream usage, so the bulk path masked it wherever usage is
reported. Where it is not, the setting had no effect at all.
2026-08-12 22:31:13 -04:00
Danny Avila
dccef82254
🪶 chore: Aggregate Empty MCP Tool Logs (#14767)
* fix: aggregate empty MCP tool logs

* fix: retain server names in MCP tool logs
2026-08-12 22:29:49 -04:00
Ravi Kumar L
9980b6221f
🪢 feat: add Langfuse session links (#14776)
* feat: add Langfuse session links

* fix: tighten Langfuse session link resolution

* fix: clear stale Langfuse session links

* test: verify tenant Langfuse session links

* fix: align Langfuse link with client conventions
2026-08-12 22:25:23 -04:00
Marco Beretta
5ff282f900
🎙️ fix: Align Speech Engine Configuration With Runtime (#14736)
* fix: align speech engine configuration with runtime

* fix: guard speech recording shortcuts

* fix: reconcile speech engine availability

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-12 13:22:28 -04:00