Commit graph

5342 commits

Author SHA1 Message Date
Danny Avila
6372cb08ee
🧶 fix: Untangle Parallel Subagent Descriptor Paths (#15492)
* fix: isolate parallel subagent descriptor walks

* fix: preserve canonical subagent config identity

* fix: separate subagent graph assembly from path validation
2026-09-01 21:48:15 -04:00
Danny Avila
6d56d3b6fe
🧴 chore: Refresh Sanitizer and Selector Parser Dependencies (#15494)
* 🔧 fix: Update sanitize-html to version 2.17.7 in package.json and package-lock.json

* 🔧 chore: Update postcss-selector-parser to versions 6.1.3 and 7.1.3 in package.json
2026-09-01 21:38:08 -04:00
Marco Beretta
2cf1007f3c
🧺 feat: Preserve Partial Work at Tool Call Limits (#15486)
* feat: Handle reaching the agent tool-call limit gracefully

When the graph exhausts its per-turn step budget, persist the turn as
unfinished with finish_reason tool_call_limit instead of a red error.
Warn the model as the budget runs low, and offer Keep going or Answer now.

* docs: Add screenshots of the tool-call-limit notice

Light and dark captures of the unfinished-turn card for the PR description.

* style: Tighten the tool-call-limit notice card

Pin dismiss to the top-right with uniform rounding, use a slightly
smaller hit target, and round the card to xl. Refresh the PR screenshots.

* fix: Address Copilot review findings on the step-budget path

Skip empty and subagent-scoped PostToolBatch events when counting
rounds. Drop the double-cast in the notice test helper and coerce the
tool-call-limit finish reason in the Continue-gate spec.

* fix: Address Codex review findings on budget and dismiss

Pass the resolved recursion limit into the OpenAI-compatible
processStream path so the step-budget hook matches the graph.
Reset the notice dismiss state when the displayed message id changes.

* fix: Unblock CI typecheck and import sorting

Declare recursion_limit on the OpenAI service agent type, sort that
file's local imports, and route the notice test context through unknown
so tsc accepts the partial ChatContext fixture.

* fix: Sort remaining PR imports for static checks

* chore: Remove tool-call-limit notice screenshots

* fix: Keep recovery actions from consuming composer drafts

Pass empty file, skill, and quote overrides on Keep going and Answer
now so a staged compose is not attached or drained.
2026-09-01 21:38:02 -04:00
Danny Avila
71544f8236
🗿 chore: Upgrade gRPC to Prevent Receive Buffer Exhaustion (#15493) 2026-09-01 21:32:20 -04:00
csdivad
8a40e9f675
🔄 fix: Reindex MeiliSearch Documents After Indexed-Projection Changes (#15484)
* 🔄 fix: Reindex MeiliSearch Documents After Indexed-Projection Changes

- Introduce _meiliIndexSchemaVersion schema field and MEILI_INDEX_SCHEMA_VERSION constant
- Stamp schema version on single-document and batch index writes
- Query unindexed and stale-version documents in syncWithMeili
- Count stale documents in pendingIndexing in getSyncProgress
- Add spec coverage for reindexing stale-version documents

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* 🔄 test: use the registered Meili conversation model

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* 🔄 style: satisfy Prettier in Meili indexing progress counts

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-09-01 21:19:22 -04:00
csdivad
ce704f5c11
🔍 fix: Include Message-Matched Conversations in Sidebar Search (#15483)
* 🔍 fix: Include Message-Matched Conversations in Sidebar Search

- Union conversation and message index matches in getConvosByCursor
- Type searchMessages with SearchParams and SchemaWithMeiliMethods
- Inject searchMessages into createConversationMethods
- Bound sidebar Meili queries with 1000 limit and attributesToRetrieve
- Fall back to title-only matches when message search fails or is uninjected
- Escape filter user values in search queries

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(search): preserve original conversation keys in sidebar hits

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* 🔍 fix: reuse the loaded conversation model

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* 🔍 fix: type the loaded conversation search model

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-01 21:17:11 -04:00
Danny Avila
164f146bdf
🛞 feat: Manage Personal Code Environment Lifecycles (#15369)
* feat: add self-service code worker pairing

* fix: enforce code worker principal boundaries

* fix: separate pairing control planes from execution

* fix: preserve principal worker routing

* fix: pin artifact traffic to code workers

* fix: authenticate code worker selection

* test: mock worker-aware artifact headers

* fix: harden self-service code worker routing

* fix: fail closed for principal code pairing

* feat: manage personal code environments

* fix: harden personal code environment lifecycle

* fix: satisfy backend lifecycle types

* fix: harden principal environment lifecycle and quotas

* fix: fence environment lifecycle races

* fix: reconcile code environment stack

* style: format code execution headers

* fix: reconcile code environment lifecycle

* fix: close self-service pairing gaps

* fix: close code environment reconciliation gaps

* fix: close code environment UI review findings

* fix: satisfy code environment type checks

* fix: route code priming to selected worker

* fix: harden code environment lifecycle fences

* fix: type lease renewal interval

* fix: harden environment lifecycle recovery

* fix: close lifecycle recovery races

* fix: type compensated agent timestamps

* fix: fence lifecycle cleanup by tenant and revision

* fix: scope environment revision fencing
2026-09-01 21:10:05 -04:00
Abhishek Biswas
38183fdc13
🧳 fix: Carry Custom Endpoint Names Through Chat URLs (#15424)
`createPayload` interpolated the endpoint name straight into the chat URL,
so a custom endpoint named `Company/API` produced
`/api/agents/chat/Company/API`. The slash reads as a path separator, the
request no longer matches the router's `/:endpoint`, and it falls through
to the catch-all handler, which answers `404 Endpoint not found`.

Encoding the name keeps it a single path segment. Express matches the
encoded segment and decodes it back, and the endpoint the server acts on
comes from the request body regardless, so nothing else has to change.

Closes #15270
2026-09-01 21:07:29 -04:00
csdivad
0f9667b391
🔎 fix: Respect Search Page Limits in MeiliSearch Queries (#15482)
* fix(api): align message search with page size

Pass a positive, capped page size to MeiliSearch and use the same conversation limit so requested message search pages are not silently truncated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(data-schemas): expand shared-link search results

Request the MeiliSearch result ceiling using only conversationId so cursor pagination is not limited to the default 20 hits or burdened with unused fields.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-01 21:05:25 -04:00
Danny Avila
a3c2277c38
🚧 fix: Narrow Chat MCP Selections to What the Picker May Offer (#15478)
* 🚧 fix: Narrow Chat MCP Selections to What the Picker May Offer

The chat MCP picker offers a server only when the operator has not hidden it
(`chatMenu: false`) and the user reaches it directly rather than through an
agent (`consumeOnly`). Nothing enforced either condition on the request, so a
stale client, a replayed body, or a hand-written one could name any server and
get its tools — `consumeOnly` in particular is a permission the registry derives
per user, so ignoring it let a user reach in plain chat a server they had only
been granted through an agent.

Narrow the picker's selection in the two ephemeral loaders, against the same
predicate the client's `selectableServers` uses. The resolver is injected, so
`packages/api` stays free of the registry and the loaders keep their existing
`deps` shape.

Provenance is why this lives in the loaders rather than at the tool-resolution
chokepoint: only here is a server name known to have come from the picker.
Servers a model spec pins are the operator's own choice and are added after the
narrowing, so pinning a chat-hidden server to a spec keeps working.

`chatMenu` only ever comes from the config tier, so the overlay settles it with
no lookup at all. Lookups for the rest run concurrently and are cached per
server and user. A name the registry cannot resolve — request-tier, declared on
the body and never registered — is kept, as is one whose lookup fails: this
narrows an already-authenticated selection and is not the authorization
boundary.

* 📝 docs: Say What chatMenu Now Bounds

The schema still described `chatMenu` as dropdown visibility alone, which is
misleading now that it also bars a server from the chat selection a request
carries. Spell out both halves, and what it deliberately does not restrict:
agents, and servers a model spec pins.

* 🛡️ fix: Close Four Gaps in the Chat MCP Narrowing

Dedupe before resolving. The selection is request-supplied and the loaders used
to build a `Set` immediately; mapping the raw array first let one body repeat a
name enough times to launch a resolver — registry, cache, and ACL reads, with no
single-flight — per occurrence.

Resolve names through the tool-loading aliases. A configured `private server` is
addressable as `private_server` downstream, so an exact-key lookup let the
normalized spelling walk past a `chatMenu: false` check and still load tools.
Names are returned as sent; only the decision is canonicalized.

Let the request overlay settle `chatMenu`. The config tier is where the flag
lives and is what the picker itself was built from, so an overlay that re-enables
a server now outranks the durable config the registry returns, which was silently
dropping a valid selection. `consumeOnly` still comes from the registry.

Narrow the request body, not just the loader's copy. `applyContextToAgent` reads
`req.body.ephemeralAgent.mcp` directly and prefers it over the agent's tools, so
a hidden server's `serverInstructions` reached the model context even after its
tools were dropped.

* 🧭 fix: Ask the Registry Which MCP Servers the Picker May Offer

Three rounds of review found the same mistake in three disguises: this narrowing
re-derived the registry's tier precedence instead of asking for it. A config
entry was treated as authoritative over a user-DB or process-backed server of the
same name; the alias map was built from config names only, so a DB server named
`private_server` was misrouted to a config `private server`; and the overlay
short-circuit ran before any of that could be consulted.

Resolve the accessible set through `resolveAllMcpConfigs` instead — the same call
behind `GET /api/mcp/servers`, so what the request may use and what the picker
was offered come from one place and cannot drift. Precedence, cross-tier name
collisions, and the config overlay are the registry's job again, and the
selectability test is a single flag check over what it returns.

The result is smaller: no overlay branch, no per-server lookups, one catalog read
regardless of how long the selection is, and aliases built over the whole
accessible set so an exact name always outranks another server's normalized form.

Publish the servers actually in play back onto the request body, spec pins
included. `applyContextToAgent` prefers that list over the agent's tools, so
narrowing to the picker selection alone had started dropping a spec-pinned
server's `serverInstructions` while suppressing a hidden one's.
2026-09-01 18:47:36 -04:00
Danny Avila
51edb2a660
🛫 feat: Enable Self-Service Code Worker Pairing (#15367)
* feat: add self-service code worker pairing

* fix: enforce code worker principal boundaries

* fix: separate pairing control planes from execution

* fix: preserve principal worker routing

* fix: pin artifact traffic to code workers

* fix: authenticate code worker selection

* test: mock worker-aware artifact headers

* fix: harden self-service code worker routing

* fix: fail closed for principal code pairing

* style: format code execution headers

* fix: close self-service pairing gaps

* fix: satisfy code environment type checks
2026-09-01 18:46:33 -04:00
Danny Avila
f72a5b33c3
🛞 fix: Roll Overflow Tenant Labels Into Aggregate Metrics (#15474) 2026-09-01 18:45:20 -04:00
Oliver Faust
5e49868f0a
🔑 fix: Decode Action OAuth Credentials After Decryption (#14698)
* 🔑 fix: Decode Action OAuth Credentials After Decryption

Action client credentials are URL-encoded before encryption, but the OAuth
token exchange decrypted them with `decryptV2` alone, sending values like
`abc%40domain.com` to the provider and causing 401s for secrets containing
`@`, `+`, `=`, `/` or `:`. Decoding falls back to the raw decrypted value so
legacy secrets stored before 299cabd6e (which contain an unescaped `%`) still work.

Fixes #14636

* 🔑 fix: Share Action Credential Codec Between Read Paths

The encode-before-encrypt convention had two readers with different
behaviour. `ActionService.decryptSensitiveValue` decoded unconditionally,
so a credential stored before encoding was introduced (299cabd6e, March
2025) containing a stray `%` threw `URIError` and failed every read of the
action, not just the token exchange.

Extract the pair into `packages/api/src/actions/crypto.ts` as the single
implementation and consume it from both `ActionService` and the OAuth token
exchange, so the two paths cannot drift again.

Tests cover the codec against real crypto: reserved characters and literal
percent escapes round-trip, pre-encoding values are returned unchanged, and
a pre-encoding value with a stray `%` no longer throws.

A version marker on newly encrypted values was prototyped and dropped: it
changes the stored format for every action credential while fixing nothing,
since values written by the encoding convention already decode correctly
and pre-encoding values stay unmarked either way.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-09-01 18:42:28 -04:00
Danny Avila
aa7539909b
🪶 feat: Enable Standalone Skill Authoring (#15480)
* feat: enable standalone skill authoring

* test: align skill e2e flow with enable toggle

* fix: preserve legacy skill state on version restore
2026-09-01 18:39:05 -04:00
Danny Avila
dfb264811c
🪢 test: Prove MCP OAuth Resume End to End (#15485) 2026-09-01 18:38:39 -04:00
Danny Avila
5b75c79831
🫥 fix: Clear Hidden MCP Chat Selections (#14159)
* 🫥 fix: Clear Hidden MCP Chat Selections

A server hidden with `chatMenu: false` stayed active in chat when it had been
selected while still visible. The selection persists to localStorage, and the
guard that clears stale selections tested the *filtered* server list — which is
empty precisely when every configured server is hidden, so the guard could never
fire and the selection survived every reload.

Gate the prune on the unfiltered catalog instead. It is non-empty in exactly the
case the old guard missed, and it stays a guard rather than a load flag so an
empty catalog — still loading, or a degraded read — is never mistaken for "the
admin removed everything" and used to wipe a selection irrecoverably.

Clear the ephemeral agent alongside the atom: it is what carries the selection to
the server, so a hidden name has to leave it too, not just the dropdown's state.

Also label the dropdown badge from the servers the menu actually offers. It
counted the raw selection, so a hidden server rendered as "Server Four" or
"2 selected" above an empty menu — the symptom that reads as a server you cannot
turn off.

* 🔧 fix: Keep Spec-Assigned MCP Servers Out of the Hidden-Server Prune

A model spec may deliberately assign a server the chat menu hides, and
`applyModelSpecEphemeralAgent` seeds it straight into `ephemeralAgent.mcp`. The
prune could not tell that apart from a stale selection, so activating such a spec
dropped its server — and because the same function re-reads `LAST_MCP_` for
existing conversations, the loss persisted across navigation.

Exempt whatever the active spec pins. `chatMenu` hides a picker entry; it does
not override an admin's spec assignment.

* 🔒 fix: Confine the MCP Selection Prune to the Hook Instance That Owns It

`useMCPServerManager` has seven call sites, and only `BadgeRowContext` reads the
chat selection — the rest mount it for the catalog, the server actions, or the
status icons. Every instance keyed to the same conversation shares one atom and
one ephemeral agent, so those six were all running the prune too, and the
always-mounted `useSideNavLinks` has no spec context to exempt from: it would
rewrite the selection the picker's own instance was deliberately keeping.

Make managing the selection opt-in, off by default at both layers, and let the
badge row alone opt in. The six catalog consumers stop touching chat selection
state entirely, which is what they should have been doing all along.

Tests now state whether they own the selection rather than inheriting it from a
default, so each one exercises the same configuration as the instance it models.

* 🔁 fix: Mirror the MCP Selection Everywhere, Prune It Only in the Owner

Gating the whole ephemeral-agent sync on selection ownership was too broad. The
action paths — `initializeServer`'s OAuth success, the revoke handler — build
their next selection from `mcpValues` and write it back through `setMCPValues`,
which also rewrites the shared ephemeral agent. An unmirrored instance therefore
wrote a stale list: authenticating server B could drop an existing selection of
server A, leaving only B.

Mirroring and pruning are separate concerns. Every instance mirrors, so each one
builds on the current selection; only the owner prunes, because only it knows
which servers a spec pins.
2026-09-01 18:38:14 -04:00
Danny Avila
6195273429
🧲 fix: Anchor Memory Partitions to Accessible Agents (#15475)
* fix: validate agent memory partitions

* fix: authorize agent memory mutations

* test: cover agent manager memory access

* fix: centralize agent memory authorization

* fix: harden agent memory authorization

* fix: support synchronous role providers
2026-09-01 18:37:52 -04:00
Danny Avila
dccd7ae496
🧶 fix: Preserve Multi-Agent Tool Call History (#15468)
* 🧭 fix: Preserve Multi-Agent Tool History

* 🧰 fix: Preserve Lazy Subagent Tool History

* 🧪 test: Enable Lazy Code History Fixture

* 🧭 fix: Derive Effective Lazy Tool History

* 🧪 fix: Tolerate Empty MCP Name Audits

* 🧪 test: Mock Lazy MCP Name Audit

* 🧭 fix: Expand Lazy Historical Tool Names

* 🧱 fix: Derive Durable Wildcard History

* 🧬 fix: Traverse Nested Wildcard History

* 🔭 fix: Resolve Wildcard Server Boundaries

* 🔐 fix: Persist MCP Call Identity

* 🪪 fix: Stamp Resolved MCP Execution Identity

* 🕸️ fix: Resolve Graph Member MCP Identity

* 🧭 fix: Fall Back From Empty Graph Member ID

* 🧭 fix: Preserve Aggregated MCP Identity

* 🧭 fix: Harden MCP Identity Resolution

* 🧭 test: Assert Concurrent Graph Limit Invariant

* 🧭 fix: Centralize Lazy History Policy

* 📦 chore: Bump `@librechat/agents` to v3.7.13

* 🧪 test: Align Content Filter Fixture With SDK History
2026-09-01 18:37:45 -04:00
Danny Avila
a290677d21
🗿 fix: Bound Background Task Retention (#15477)
* fix: bound background task registry memory

* fix: preserve background registry capacity state

* fix: align background payload accounting

* fix: harden background capacity admission

* fix: honor background payload admission

* fix: make background eviction atomic
2026-09-01 16:55:00 -04:00
Danny Avila
ce209102b8
🧫 fix: Contain Detached Tool Output (#15476) 2026-09-01 16:54:41 -04:00
Danny Avila
0f585497ea
🚮 fix: Replace Backtracking LangChain Error Regexes (#15473) 2026-09-01 16:52:48 -04:00
Danny Avila
6122d210da
🛄 fix: Reclaim Pending MCP OAuth Prompts on Resume (#15471) 2026-09-01 16:52:18 -04:00
Danny Avila
74535662fe
🎖️ fix: Apply Fallback Role on Group Overage (#15472) 2026-09-01 16:44:24 -04:00
Danny Avila
67e2107d74
🔐 fix: Keep OpenID Metadata Out of Logs and Traces (#15470)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
* 🔒 fix: Keep Secrets Out of Debug Logs and Spans

* 🧯 fix: Derive Safe Exception Types

* 🛡️ fix: Close Diagnostic Redaction Gaps

* 🧼 fix: Redact Escaped JSON Secrets
2026-09-01 13:22:12 -04:00
Danny Avila
d83958d3b3
📎 fix: Keep SharePoint Picker Selections While Navigating (#15389)
The picker never set `selection.enablePersistence`, so it dropped the current
selection every time the user opened another folder or switched pivot. A selection
could only ever be assembled from a single location — pick three files, open another
folder, and the three are gone.

One option on the picker configuration; no change to what LibreChat does with the
result.

Fixes the selection half of #15237. Folder selection — the other half — is a new
capability rather than a fix, and is parked on `feat/sharepoint-folder-selection`.
2026-09-01 12:59:50 -04:00
Danny Avila
537ddba6bb
🫴 fix: Hand Composer Focus Back After Upload Dialogs (#15469)
Typing a message, pasting an image, then pressing Enter did nothing: the
composer held the full draft, the send button was enabled, and Enter was
inert until the user clicked back into the textarea — which reads as
"the textarea needs a text change before it will submit".

The send gating was never at fault. Radix's modal `DialogContent` always
cancels its default close-focus restore and focuses
`context.triggerRef.current` instead. `DragDropModal` — the "Select Upload
Type" dialog a paste or a drop opens — is opened programmatically and so has
no `Dialog.Trigger`, leaving that ref null: nothing is focused and focus
falls to `document.body` when the dialog unmounts. Enter-to-send is a
keydown handler on the textarea, so from `body` it is never reached.

`UploadModalProvider` now records the element the upload was started from,
captured in `openModal` before the dialog mounts (an effect runs too late —
Radix has already moved focus), and restores it once the dialog closes,
queued behind Radix's own deferred restore. This covers every close path:
picking a destination, Cancel, Escape, and dismissing the overlay.

`OGDialog`'s existing `triggerRef` seam does not reach this case, since it
only fires through Radix's `onOpenChange` and choosing an upload destination
calls `closeModal()` directly.
2026-09-01 12:56:03 -04:00
Danny Avila
c3beae1e18
🔌 refactor: Give the Subagent Surface a Host Seam and Its Own Jotai State (#15455)
* 📐 docs: Record Who Owns Which Piece of Client State

* 🔌 refactor: Give the Subagent Surface a Host Seam and Its Own Jotai State

The subagent chat surface reached up into seven application atoms and could
not be lifted anywhere, and the six atoms it owns were still Recoil while its
sibling stores had already moved to Jotai.

Feature-owned state converts to Jotai and stays where it is: the selection,
per-task control receipts, live progress and the parent-stream fence. The
control atom keeps its session-storage retry — restored per store rather than
per family member, so a reload still sees what the last one persisted.

App-global state now arrives through one `ChatSurface` value the host supplies:
`enterToSend`, `maximizeChatSpace`, `showScrollButton`, the reader's composer
bindings, a `claimForeground` command for the slot the panel and the artifacts
panel share, and `handOffComposerText` for a continuation's unsent words. Two
hosts mount the same adapter — the conversation view and the shared-link view.

Opening the panel was three copies of "clear the artifacts slot, then select";
that is now `useOpenSubagentPanel`, and the three call sites say what they mean.

Nothing under `Chat/Subagents/**` imports `~/store` or `recoil` any more, and
its whole suite runs with no `RecoilRoot` at all.

* 🧹 refactor: Move Subagent State Into the Feature and Free Its Drained Atoms

Two review findings, both fair.

The selection atom still lived in `~/store`, which handed the supposedly
liftable surface an implicit dependency on the application store alongside its
declared host interface — and contradicted the ownership rule this branch
added to CLAUDE.md two commits ago. The module moves to
`components/Chat/Subagents/state.ts`; the hosts that write it (the SSE step
handler, the message renderers) now import from the feature, which is the
direction that survives an extraction.

The conversation-switch drain blanked its two atom-family members but left
them cached. Every invocation key is unique, so a long-lived tab accumulated
two atom configurations per subagent call it ever saw — exactly what the
comment at that boundary says it exists to prevent. `removeSubagentProgressAtoms`
releases them after the values are cleared, mirroring `removeUsageAtoms`. The
new test fails without the release.

* 🔎 fix: Let a Subagent Card Render Where There Is No Panel to Open

Search results render the same card through `Part`, and that route is not
beneath either chat surface — so a search hit containing a subagent tool call
threw on `useChatSurface()` before it could render at all.

Rendering the card and opening a panel are different requirements. The host
stays required for the panel, the activity view and the event group, all of
which only ever mount inside one. `useOpenSubagentPanel` reads the context
optionally instead and answers `null` where there is no host, which is also
the honest answer for search: there is no panel on that route to open, so the
card renders with its details control disabled.

The new test renders the card with no surface at all, and throws with the
reported error when the hook is put back the way it was.

* 🧮 fix: Enrol Read-Created Progress Atoms in the Drain Registry

The read is what creates a family member, and only the streaming paths were
registering their keys. A card rendering a conversation that finished
streaming long ago — or a search result that never streams at all — created
one the navigation drain could not see, so browsing history accumulated an
atom configuration per card for the life of the tab.

`useSubagentProgress` does both halves: it reads the invocation's progress and
enrols its key, so every route into the family shares the one cleanup
boundary. The panel and the parent card both read through it now.

* 🧽 fix: Free a Read-Created Progress Atom at Its Own Card's Unmount

Enrolling read-created keys in the drain registry only helps where something
runs the drain, and `/search` is a standalone route that never mounts the chat
event handlers. Scrolling a virtualised result list therefore kept minting
members nothing would ever free.

`useSubagentProgress` cleans up after itself instead: on unmount it frees the
family members when the read is the only reason they exist. Two guards keep
that from taking live history with it — a key the stream registered belongs to
the drain, and a member holding folded activity is the record of what the child
did, so reopening its card still shows it.

* 🔗 fix: Free a Progress Atom Only After Its Last Reader Lets Go

Opening a foreground panel before the first event gives one still-empty member
two readers: the card that spawned the child and the panel showing it. Closing
the panel freed it while the card was still subscribed — and a subscriber does
not re-render just because its atom left the family, so the first SSE update
landed on the replacement member and the card's ticker stayed dead.

`useSubagentProgress` counts its mounted readers and frees only when the last
one lets go, alongside the two claims that already blocked it: a key the stream
registered, and a member holding folded activity.
2026-09-01 11:15:42 -04:00
Danny Avila
a34d5dbfc7
🪂 fix: Preserve Subscribers Through Terminal Handoffs (#15462)
* fix: preserve fenced terminal subscriber handoff

* test: parse replay conversation pathname

* fix: capture late terminal subscribers
2026-09-01 11:15:24 -04:00
Danny Avila
c8939c9730
🪄 fix: Reuse Downloaded Artifacts for Image Reads (#15454) 2026-09-01 10:55:53 -04:00
Danny Avila
8f6228095f
🖼️ fix: Stop Reporting a Failed Sandbox Image Read as an Unreadable Format (#15453)
* 🖼️ fix: Stop Reporting a Failed Sandbox Image Read as an Unreadable Format

`read_file` on a `/mnt/data` image collapsed every failure — rate limit,
missing path, truncated transfer, transport error — into the binary-file
hint, whose image branch reads as a permanent capability limit ("is an
image file and cannot be read as text"). The model stopped retrying, and
the operator had no way to tell which failure had actually occurred.

The failure that surfaced it: the reader windows the bytes through
`/exec` stdout, one call per window, against the Code API's per-user
execution limiter (20 requests / 30s in the reference deployment). At a
fixed 32KB window a 122KB chart cost 4 calls and a 1MB image cost 33 —
more than a whole limiter window, so anything over ~640KB could never
complete. A turn that generates several charts and reads them back
exhausts the budget partway through and reports the 429s as unreadable
images.

- `handlers.ts`: failed reads name the real cause and the affordance that
  matches it — rate-limited waits and retries, a missing path lists the
  directory, a truncated transfer retries. The "cannot be read as text"
  phrasing is now reserved for bytes that are not a supported image. An
  image past the inline budget says to downscale and read the smaller
  copy rather than pointing at `bash_tool`, which cannot show an image.
- `process.js`: the window is derived from the runner's stdout budget
  (48,960B vs 32,768B, so a 122KB chart is 3 calls and 1MB is 22), halves
  and re-reads the same offset when a runner truncates anyway (remembered
  per Code API, with the env remedy logged), waits out a rate limit it
  can afford instead of discarding the bytes already pulled, and stops at
  a 24-call ceiling so no read stalls a turn across limiter windows.
- `utils/code.ts`: shared `getCodeApiRetryAfterMs` (header, then the
  `retry_after_seconds` body); `skillFiles.ts` drops its private copy.

* ♻️ refactor: Move the Sandbox Image Reader Into TypeScript

Addresses the review round on #15453.

- P1: windowing, window sizing, the in-sandbox reader script, response
  parsing and byte assembly move to `packages/api/src/files/code/image.ts`,
  and the Code API rate-limit policy to `packages/api/src/utils/code.ts`.
  `process.js` keeps only the `/exec` transport it owns. Both new modules
  are tested against their real implementations (19 + 13 cases), including
  the round-trip ceiling, the changed-file guard and the shell-injection
  guard on the reader script.
- P2: a configured stdout budget below the old 8KB window floor produced a
  window the runner could never emit, and narrowing was pinned to the same
  floor, so no retry could succeed. The window now aligns to the base64
  group size instead, and the round-trip ceiling bounds what a small budget
  costs.
- P2: `.env.example` documented a 32,768-byte default that no longer holds
  and never mentioned the stdout budget. It now describes
  `LIBRECHAT_CODE_SANDBOX_OUTPUT_MAX_SIZE` as the setting to tune, with the
  chunk override as the escape hatch.
- P3: `process.spec.js` no longer reimplements the retry parser in its
  module mock. Behaviour is tested where it lives; the mock is a declared
  passthrough and the spec covers what the transport still owns — session
  forwarding, the profile header, parser hand-off and error logging.
- P3: `getCodeApiRetryAfterMs` reads the response body through `in`
  narrowing rather than an `as` assertion.
- P3: the window-sizing JSDoc no longer claims a multiple of 3 in the one
  case that is not — an explicit `LIBRECHAT_CODE_IMAGE_CHUNK_BYTES`.

* 🎯 fix: Size Sandbox Image Reads to What the Limiter Can Deliver

Addresses review round 2 on #15453.

- Narrowing now halves the caller's own failing window instead of the
  shared learned value, and the learned value only moves down to the
  narrowest size any read needed. Reads run concurrently (one per tool
  call), so two reads failing at the same size were compounding into a
  window two steps smaller than the runner would have accepted, and
  persisting it for every later read.
- Narrowing continues until a runner accepts a window or a floor below
  which no runner could serve an image at all. Two halvings stopped at
  12,240 bytes — still ~16KB of base64 — so an 8-10KB stdout cap that was
  not configured up front stayed unreachable.
- The round-trip ceiling is now one limiter window (20), and a read that
  cannot finish within it says so from the FIRST response rather than
  spending the whole budget rediscovering it: the first window reveals
  both the file size and what a call really delivers. A near-limit image
  no longer drains the turn's executions before failing, which is what
  made the wait budget look too small.
- The round-trip message names the size this deployment can actually
  inline, so the model has a downscale target instead of a guess.
- `python3: not found` from the sandbox reader was matching the generic
  "not found" in `isSandboxMissingFileError` and being reported as a
  missing image path, sending the model to `ls /mnt/data` while hiding a
  runner dependency. Image reads now classify only path-specific ENOENT
  forms as a missing file.

* 📐 fix: Size a Truncated Read's Retry From the Cap It Revealed

Addresses review round 3 on #15453.

A ~1KB sandbox stdout cap is a real runner default, and the 1,536-byte
narrowing floor sat above it: discovery stopped one step short and
declared every image unreadable, even though small ones fit comfortably
in the 576-byte window the configured path already derives.

Narrowing now runs until no smaller window exists. It also no longer has
to grope its way down: a truncated response emitted exactly as much as
the runner allows, so its length measures the cap this deployment never
declared, and the retry is derived from it directly — one round-trip
instead of the eight blind halvings it takes to fall from ~49KB to ~1KB.
Blind halving remains the fallback and bounds the result either way, so
every retry is strictly smaller than the window that just failed.
2026-09-01 09:08:01 -04:00
Danny Avila
cdfe54c349
🚦 fix: Downgrade Stale Generation Fence Telemetry (#15452)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Docker Dev Images Build / publish (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* fix: downgrade stale generation fence telemetry

* fix: preserve fenced persistence failures

* fix: avoid duplicate fence warnings

* fix: propagate terminal publication fences

* style: format fencing audit changes

* fix: keep successor transitions generation scoped

* fix: bound predecessor handoff retirement

* fix: reconcile discovered successors completely

* fix: preserve successor handoff acknowledgement
2026-09-01 03:00:28 -04:00
Danny Avila
97e1e950ad
🧲 fix: Draw OpenID Refresh Lookups Into System Context (#15451)
* fix: scope refresh user lookups to system context

* fix: preserve refresh system context

* fix: bind refresh recovery to user tenant
2026-09-01 02:02:21 -04:00
Danny Avila
224512851a
🗂️ fix: Resolve Code Interpreter Destination Collisions (#15449)
* 🗂️ fix: Resolve Code Interpreter Destination Collisions

Codeapi mounts each `/exec` input at a destination derived from its `name`
and rejects the whole request when two entries land on one. LibreChat never
enforced that: `primeFiles` sent `name: file.filename` verbatim, and the
graph seed deduped by `(storage_session_id, id)` — identity, not destination.

Two conversation files sharing a filename therefore killed code execution for
the rest of the conversation. The rejected request never reaches the sandbox,
so no result comes back to collapse the pair, and every later turn re-primes
both and fails identically. Only code-generated outputs are covered by the
`(filename, conversationId, context, tenantId)` partial unique index; uploads
carry `context: message_attachment`, so one conversation can hold several
records sharing a filename — two uploads of `image.png`, or an upload whose
name a later execution wrote back in place.

`packages/api/src/files/code/destinations.ts` now owns the name → destination
mapping for every contributor to an `/exec` file list, mirroring codeapi's
conflict rule including directory-prefix conflicts:

- `primeFiles` claims per pushed file, disambiguating with a `-2`, `-3`
  counter and echoing the resolved path into the tool context, so the model
  is told where each file actually mounts. Claim order comes from immutable
  `createdAt`, not `getFiles`'s `updatedAt` sort, which usage accounting and
  re-upload both bump — order-dependent destinations would silently repoint
  a path an earlier turn had already handed the model. Newest wins the bare
  name, matching `ToolNode.updateCodeSession` and giving an in-place rewrite
  precedence over the original it superseded.
- `seedCodeFilesIntoSessions` drops a later file whose destination is already
  claimed. Renaming there would invent a path nothing told the model about,
  and a conflict at that layer means one logical file arrived twice under
  different storage pointers.

Fixes #15443

* 🧹 chore: Tolerate Holes in the Destination Priority Sort

`primeFiles` sorts the raw `getFiles` result before its own per-entry guard
runs, so the comparator has to survive a null row rather than throw ahead of
the loop that was already defending against one.

Also tightens the tool-context assertion in the upload-vs-output case, which
was passing on a trailing space rather than on the path.

* 🔁 fix: Address Codex Review on Destination Assignment

Four findings from the review of `b0f61d0`.

**Identity-derived suffixes instead of a collision counter.** A counter is
assigned from whatever is in the set, so a third file arriving under the
literal name a displaced file had been given pushed that file along, and code
the model wrote in an earlier turn would silently start reading the newcomer.
The fallback now hashes the file's own `file_id`, so a displaced file keeps
its destination for the life of the conversation and only the winner of the
bare name can change. A trailing counter still guards a hash collision so the
search terminates on any input.

**Rank by last content write, not record creation.** `processCodeOutput`
claims one row per `(filename, conversationId)` and rewrites it in place, so
a repeatedly written output keeps its original `createdAt` while recording the
latest writer in `metadata.sourceDispatchedAt`. Ranking on `createdAt` alone
handed the bare path to an upload the output had since been rewritten over.
Recency is now `max(sourceDispatchedAt, createdAt)` — still never `updatedAt`,
which usage accounting bumps without any content changing.

**Advertise a displaced generated output.** Generated files are left out of
the tool context because the model already knows what it wrote — which stops
being true once a newer same-named file takes the bare path and the output
mounts somewhere the model has never seen. Displaced outputs are now listed
with the name they were written as, and displaced uploads name their original
too.

**Rank conversation files above an agent's private ones.** Each agent claims
destinations over its own candidate set — the conversation's files plus its
own — so a private file taking a bare name from a shared file in one agent but
not in another left two agents advertising different paths for the same file
into one shared mount namespace. Shared files now rank first, so the majority
that every agent sees resolves identically. The private-versus-private case
still drops at the merge and is documented there; closing it needs one
assignment across contributors, made before any agent renders its context.

* 📌 test: Pin Alias Ownership on an In-Place Rewrite

Codex round 2 flagged that the identity alias still falls back to a counter
when the alias itself is literally claimed, so a displaced file can move after
all. The mechanism is real and my JSDoc overstated the guarantee, which this
corrects.

The behaviour stays as it is. The realistic way a file arrives under another
file's alias is the model rewriting the displaced file in place: it was told
`<stem>-<hash>.ext`, wrote back to it, and `processCodeOutput` registered an
output under that literal name. Keeping the alias with the newest holder is
what makes the model's familiar path resolve to its own edit; reserving it
against the newcomer would hand that path back to the bytes it replaced. The
test pins that ordering so a later change cannot invert it silently.
2026-09-01 00:55:54 -04:00
Danny Avila
09722f4b16
✔️ style: Anchor the Applied Steer Receipt and Name Tool Calls in Its Interrupt Copy (#15450)
* ✔️ style: Anchor the Applied Steer Receipt at the Row's Trailing Edge

* 💬 style: Say Tool Calls Are Not Interrupted Either
2026-09-01 00:53:24 -04:00
Danny Avila
1ff4a44b61
🩹 fix: Stop the Subagent Composer Unmounting, and Give It Main Chat's Shape (#15448)
* 🩹 fix: Stop the Subagent Composer Unmounting, and Give It Main Chat's Shape

Measured with react-scan and a DOM presence probe against the live panel:
every external delivery unmounted and remounted the composer inside 10ms,
taking focus and any half-typed steer with it.

- The composer stays mounted across a delivery. A delivery re-keys the
  thread query to its new task and the task view blanks for the render or
  two that takes — deliberately, so task-scoped fields are never
  misattributed — but presence followed it down. The last mode the thread
  showed now carries the surface across that gap, holding submission until
  the new task's own view lands. `dispatched` also counts as live, so a run
  that has only just started no longer withdraws the field either.
- One control, not three labels. Submitting IS the steer; with nothing to
  send the send button becomes main chat's Stop, which cancels the task.
  Queue and interrupt keep the chords main chat gives them
  (⌘/Ctrl+Enter, ⌥/Alt+Enter) instead of spelling themselves out beside
  the field, and every control says what it is through its tooltip and
  accessible name rather than a word label.
- The two composers line up. The field takes `ChatForm`'s own metrics and
  the footer clears the viewport floor by the height of main chat's
  disclaimer, so both textareas now measure top 805 / height 52 and both
  surfaces end at 910 in a 950px viewport.
- The header is main chat's header: a 52px bar that floats over the thread
  and fades into it, so the conversation scrolls under it instead of
  starting below a solid bordered bar.

Residual re-renders are the per-second timestamp and elapsed tickers plus
the 2s poll both queries run while a child is live — shared with main chat,
about 2ms of render time across a 14s window, and left alone.

* 🎛️ fix: Bind the Chord to Its Own Submission, and Keep Queue Reachable

Codex and Copilot, round one.

- A refused chord no longer decides a later click. `Composer` hands the
  submitting event to its host, and the panel keeps the resolved control
  bound to THAT event — a ⌘/Ctrl or ⌥ chord the composer then refuses
  (empty field, settling, a command in flight) leaves nothing behind for
  the next pointer submission to pick up.
- The control comes from the reader's own key policy, not from raw
  modifiers. With Enter-to-send off, ⌘/Ctrl+Enter IS the submit chord, so
  reading modifiers directly made steering unreachable; the verdict from
  `resolveComposerKeyDown` decides instead.
- Queue and interrupt keep a pointer. Chords alone stranded touch readers,
  and anyone whose shortcuts are disabled, rebound, or yielded. They are
  back as icon-only controls that say what they are through their tooltip
  and accessible name rather than a word label.
- Stop cannot render unlabelled. Handler and label travel as one value
  (`ComposerStopProps`), so the type refuses a Stop without a name.

* 🔗 refactor: Hang the Panel's Alternate Submissions Off One Shared Send Control

Queue and interrupt had no pointer, then briefly had controls of their own
beside the field — which repeats what submitting already does. Main chat
never did that: it hangs its during-run actions off the send button.

- `SendActions` moves to `@librechat/client`: the hovercard, the rows, the
  chord chips and their classes, lifted out of `DuringRunSendButton`
  unchanged. Each host passes its own anchor whole, so main chat keeps its
  ref (Enter's synthetic click routes through it), its submit type, and its
  identifying attributes. Its 13 existing tests still cover the rows.
- The panel offers steer, queue and interrupt from that same control, and
  advertises a chord only while it still reaches its action — each hint is
  read from the decision table that will execute it, so a rebound, yielded
  or disabled chord advertises nothing.
- The send control names what IT does (`com_ui_steer_send`) as distinct
  from the `Steer` row it offers, exactly as main chat pairs them.

Two hosts now share one implementation, which is what the seam wanted.

* 🧹 chore: Sort Panel Imports and Drop an Unrelated Reorder

The import sort ran before the last edits to this file. Also reverts a
Tailwind class reorder `eslint --fix` made to `MultiSearch`, which this
change has no business touching.

* 🛡️ fix: Only Offer Controls a Dispatched Task Can Actually Take

Codex, round two.

- A dispatched task is controllable only once the server can address it.
  `control.ts` answers 404 when the task has no durable input row and no
  live lease, which this panel reads as an inaccessible task and closes its
  controls for good — so treating every dispatched task as controllable
  could brick them. The field still stays (withdrawing it as a run starts
  is the swap this panel exists to avoid); submission waits for evidence.
  A task whose controls are definitively closed holds nothing.
- Semantic status roles replace `text-amber-500`, `text-cyan-500` and
  `text-red-500` in the action rows — in main chat's list too, since both
  now render through the same module.
- One popup over the send control. With actions to offer, the tooltip is
  withheld: both attached to the same button, opened on the same focus,
  above the same point, and the tooltip's higher z-index covered the list.
- Where the pointer cannot hover, the alternate submissions are controls of
  their own: a tap on the hovercard anchor submits, so touch readers could
  not reach queue or interrupt at all.

* 🚪 fix: Send Every Control Through the Same Addressability Gate

Codex, round three — both findings were doors around the gate added last
round.

- Stop and the inline actions now answer to `controlAvailable`, not just to
  `composerMode`. A retained surface over a dispatched task the server
  cannot address yet still offered Stop, and the touch-side queue and
  interrupt buttons were live too; each is a command, and each 404 closes
  this task's controls for good.
- A trapped panel keeps its actions inside itself. At mobile width with a
  hover-capable pointer the list still hung off the send control, portaled
  outside the `aside` the focus trap knows — so Tab never reached it. The
  actions come inline whenever the panel is a modal, hover or not.
2026-09-01 00:29:22 -04:00
Danny Avila
083be12ddc
🫂 ci: Unify Docker Image Publishing Workflows (#15447)
* 🛰️ ci: Publish Every Image Through One Native-ARM Workflow

#15441 removed QEMU from the dev-branch image build and #15444 fixed the
prefix-glob bug it shipped with. Both changes lived in one of five workflows
that build images the same way; the other four still emulate arm64 with no
layer cache and take 20-40 minutes.

Rather than paste the pattern four more times, the build/merge logic moves to a
reusable `docker-publish.yml` that every image workflow calls. #15444 is the
argument for this: one wrong artifact-name expression would otherwise have
needed correcting in five files, and the same prefix hazard exists in all of
them (`librechat`/`librechat-api`, `librechat-dev`/`librechat-dev-api`,
`lc-dev-staging`/`lc-dev-staging-api`). The shared implementation is the
dev-branch one that already ran green, generalised only where callers differ.

Callers pass what actually varies:

- `images`   — the target/file/image_name triples
- `tag_suffixes` — newline-separated tags applied in both registries
- `checkout_ref` / `build_branch` — main-image-workflow builds `main`, not the
  dispatch ref

`main-image-workflow` and `tag-images` resolve their tags from git history, so
each grew a small `resolve-tag(s)` job whose output feeds the publish. That also
removes a duplicated `git fetch --tags` that previously ran once per matrix leg.

Two details worth noting:

- The digest-count assertion now derives its expected value from the `arches`
  input rather than a hard-coded 2, so the architecture list has a single
  source of truth shared by the build matrix and the merge guard.
- Nothing is interpolated into a `run:` block any more; `build_branch` and the
  build digest arrive through `env`, so a ref name cannot reach a shell.

Net 135 lines lighter, and QEMU no longer appears anywhere in .github/workflows.
Workflow names are unchanged, so retry-docker-builds.yml still matches all five.

* 🔗 fix: Pin Release Builds to One Commit and Survive Full Re-runs

Review findings on the reusable publish workflow.

**P1 — every build leg could resolve `main` separately.** `main-image-workflow`
passed the symbolic ref `main` as `checkout_ref`, so the four build jobs each
resolved it at whatever moment they were scheduled. If `main` advanced in
between, the amd64 and arm64 halves of a release — or the API and application
images — could be built from different commits and published under the same
release and `latest` tags. `resolve-tag` now exports the commit it resolved the
tag from, and the publish pins to that SHA. The single-job workflow this
replaced checked out once, so this was a regression introduced by the split.

`docker-publish` likewise now falls back to `github.sha` rather than passing an
empty `ref` to `actions/checkout`, so callers that omit `checkout_ref` also get
an immutable commit instead of relying on checkout's empty-ref handling.

**P2 — digest artifacts on a full re-run.** `upload-artifact@v4` will not
upload over an existing artifact name, and a full re-run keeps the run id, so a
retry would fail before the merge could run. The suggested fix was to qualify
the artifact name with `run_attempt`, but `retry-docker-builds` picks its
endpoint by conclusion: `rerun` only when a job was cancelled, and
`rerun-failed-jobs` otherwise. Under the latter the merge re-runs alone and must
still find digests uploaded by the *previous* attempt's build legs, which a
run_attempt-qualified name would hide — precisely the shape of the #15444
failure, where the builds passed and only the merge failed. `overwrite: true`
resolves the conflict while keeping names stable across attempts.

**Unreachable error branch.** `LATEST_TAG=$(git tag ... | grep ... | head -1)`
under `set -euo pipefail` exits on grep's non-match, so the "No stable v<semver>
tag found" message could never print. Added `|| true`, matching what
`tag-images` already does. Pre-existing, carried over verbatim in the split.
2026-08-31 23:29:57 -04:00
dymux
22a9368604
🛄 fix: Refresh Stale Skill File References (#15413)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / Build lc-dev (linux/amd64) (push) Waiting to run
Docker Dev Branch Images Build / Build lc-dev-api (linux/amd64) (push) Waiting to run
Docker Dev Branch Images Build / Build lc-dev (linux/arm64) (push) Waiting to run
Docker Dev Branch Images Build / Build lc-dev-api (linux/arm64) (push) Waiting to run
Docker Dev Branch Images Build / Merge manifests (lc-dev) (push) Blocked by required conditions
Docker Dev Branch Images Build / Merge manifests (lc-dev-api) (push) Blocked by required conditions
* fix(agents): check skill version in codeEnvRef cache-hit path

When a SKILL.md body is edited, `updateSkill` bumps `Skill.version`
but does not clear the `codeEnvRef` on bundled skill files. The
cache-hit path in `primeSkillFiles` reused those stale refs without
comparing versions, causing Code API to return 403 ("Unauthorized file
reference") because the session key no longer matched.

Add a version check to the cache-hit guard: a ref whose version does
not match the current skill version is treated as a cache miss, forcing
a fresh upload under the new session key.

Closes #15275

* fix(agents): apply version check to aggregate cache path in primeInvokedSkills

The previous commit added a version check to the per-skill cache-hit
path in primeSkillFiles. Codex review pointed out that
primeInvokedSkills has its own aggregate cache-hit branch that returns
cached refs before delegating to primeSkillFiles. That branch checked
ref presence and session activity but not ref.version against
skill.version, so a stale ref from an older skill revision would still
bypass the guard.

Add the same version comparison to the aggregate filter and include the
skill object in the allResolved entries so the version is accessible.

Addresses Codex review on #15413

* fix(agents): type skill cache version checks

---------

Co-authored-by: dymux <putramkti@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-31 22:32:20 -04:00
Danny Avila
a1e92e5530
🧩 ci: Scope Each Image's Digest Artifacts to Its Own Manifest Merge (#15444)
The first native-ARM run published `lc-dev-api` correctly but failed merging
`lc-dev`:

  Merging 4 platform digests: 18f83e4a... b4700f27... ca747c51... edf23a05...
  ERROR: ghcr.io/danny-avila/lc-dev@sha256:ca747c51...: not found

`lc-dev` is a prefix of `lc-dev-api`, so the merge job's
`pattern: digests-lc-dev-*` matched all four uploaded artifacts — both images'
digests — and then tried to resolve an API digest against the app repository.
`lc-dev-api` was unaffected because no other image name prefixes it.

Two changes:

- Artifact names carry an explicit `-arch-` separator, so one image's pattern
  can no longer reach another's digests.
- The merge asserts an exact digest count rather than merely rejecting zero.
  The old guard only caught an empty set, which is why four sailed through. Too
  few digests would publish a manifest missing an architecture; too many means
  the glob crossed images. Either is now a hard failure before anything is
  tagged.

No bad manifest was published: `imagetools create` failed while resolving its
sources, so `lc-dev` kept pointing at its previous build. The run also confirms
the approach itself works — `lc-dev-api` merged to a proper index carrying
linux/amd64 and linux/arm64, and the native arm64 legs built in 3-4 minutes
against 13+ minutes under QEMU.
2026-08-31 22:29:33 -04:00
Danny Avila
283aae49b3
🖼️ fix: Give a Phase's Files Their Own Media Row (#15435)
Some checks failed
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / Build lc-dev (linux/amd64) (push) Waiting to run
Docker Dev Branch Images Build / Build lc-dev-api (linux/amd64) (push) Waiting to run
Docker Dev Branch Images Build / Build lc-dev (linux/arm64) (push) Waiting to run
Docker Dev Branch Images Build / Build lc-dev-api (linux/arm64) (push) Waiting to run
Docker Dev Branch Images Build / Merge manifests (lc-dev) (push) Blocked by required conditions
Docker Dev Branch Images Build / Merge manifests (lc-dev-api) (push) Blocked by required conditions
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `@librechat/client` to NPM / pack (push) Has been cancelled
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
* 🖼️ fix: Give a Phase's Files Their Own Media Row

* ✂️ refactor: Drop the Inline-Media Dedup

Six review rounds produced thirteen findings. The media row accounts for
one and inline resolution for five, all fixed — both reason about
attachments, and both converged. The dedup accounts for seven, arriving at
roughly one per round (code fences, backtick spans of two widths, escapes,
indented blocks, invalid title syntax, and raw HTML blocks), because it is
the only part that has to know what markdown RENDERS. Each miss claimed a
file nothing displayed and took it out of the row, so the standing cost of
keeping it was a recurring way to hide content, in exchange for avoiding a
duplicate.

So it goes. `collectInlineMediaNames`, the fence tracking, the standalone
image-line whitelist, and the claim wiring in `ContentParts` are deleted,
along with the raw-HTML finding, which no longer has anything to apply to.

Both reported problems still ship. The phase card's files ride their own
row outside the fold, and a bare `![DTI](5_dti.png)` still resolves to the
file the run produced. The only loss is that a chart the model inlines now
appears twice — once in the row, once where the model put it — which is
noise rather than a defect, and cheap next to a file that appears nowhere.

What remains reasons only about attachments: which stored file a name
refers to, whether that name is ambiguous, and which paths this app
serves.

* 🧹 style: Restore import order after the media context import shortened

* 🪞 fix: Address Codex Round 7 — Latest Record and Dimension-Free Fallbacks

The name map kept the FIRST record of a file while `collectPhaseAttachments`
had been changed to keep the latest — the row and the inline reference
disagreeing about which record of one file is current. A tool rewriting a
path reuses its `file_id` and the later record carries the newer URL, so
the inline reference resolved to a stale one. Both surfaces now take the
latest, matching the attachment renderer.

Resolution was gated on `isImageAttachment`, which additionally demands
width and height. Those exist so `<Image>` can reserve layout space; a
markdown `<img>` needs neither. The download fallback an oversized output
produces carries an image name and a working URL and displays perfectly
well, and requiring dimensions dropped it — leaving `![chart](chart.png)`
broken for exactly the outputs big enough to matter. The gate is now what
an `<img>` actually requires: an image extension and a fetchable path.

That predicate also moves INSIDE `buildAttachmentsByName`, where the
contract belongs, rather than being a filter the one caller had to
remember. Reusing `isImageAttachment` was the wrong kind of DRY — it
answers a different question than the one asked here.

* 🧬 fix: Address Codex Round 8 — Run-Step Identity and the Recovery Renderer

Two of three; the artifact-panel one is deliberately left, see the PR
thread.

The row's dedup key omitted `stepId`, but `filterAttachmentsForPart`
routes records to their owning part BY run step — so two executions that
reuse a provider tool-call id and claim the same `file_id` are two
artifacts, and collapsing them dropped one from the row that, once the
fold hides the parts' own copies, is the only place it appears.
`attachmentRenderKey` adds that dimension on top of `attachmentIdentity`
rather than changing it: the merge in `useAttachments` reconciles DB rows
against live records whose `stepId` may not have arrived, so it must stay
tolerant of a field one side can lack. A step-less legacy row keeps the
plain identity and never splits from itself.

`MarkdownErrorBoundary`'s fallback renderer omitted the attachment-aware
`img`, so a generated reference reverted to a page-relative URL precisely
in the recovery path — where the media context still holds the file and a
broken image is least welcome.
2026-08-31 22:05:27 -04:00
Danny Avila
d3ea6f415d
🐆 perf: Build Dev Branch Images on Native ARM and Cheapen Asset Brotli (#15441)
The dev-branch image build takes 20-40 minutes and has hit its 130-minute
timeout. Neither cause was in the bundler.

Per-step timings from run 33424998112 show QEMU is ~84% of the work:

  npm ci             52.6s (amd64)  vs  333.2s (arm64, QEMU)  — 6.3x
  npm run frontend   90.4s (amd64)  vs  442.8s (arm64, QEMU)  — 4.9x

So build each platform on a runner of its own architecture, push by digest,
and stitch the digests into the multi-platform manifest lists in a merge job.
The tags consumers pull are unchanged. Layer caching goes to GHCR beside the
image rather than `type=gha`: the Actions cache is capped at 10GB repo-wide
and is branch-scoped, so `dev` and `main` runs would not share it, while a
registry cache has no cap and is reusable by the other four image workflows
when this pattern is rolled out to them.

Separately, vite-plugin-compression2 defaults to brotli quality 11, which
costs ~13s of CPU on this 18.4MB bundle against ~0.47s for gzip-9, and the
plugin's scheduler serializes quality >= 10 as a high-memory operation so it
cannot use the runner's cores. Quality 5 compresses in ~0.2s and still lands
under gzip (4.3MB vs 5.0MB). `.br` is only served when
ENABLE_STATIC_ASSET_BROTLI is set, which is off by default, so the 0.5MB the
top quality buys is paid for on every build of every platform and rarely
collected. The client build goes from ~11s to ~4s; gzip output is unchanged.

This is a pilot on the dev-branch workflow only. dev-images,
dev-staging-images, main-image-workflow and tag-images still use QEMU and
still have no layer cache.
2026-08-31 22:05:13 -04:00
Danny Avila
bd24e45489
🧵 refactor: Give the Subagent Panel One Composer and Main Chat's Metrics (#15436)
* 🧵 refactor: Give the Subagent Panel One Composer and Main Chat's Metrics

The panel drifted from main chat in the small measurements a reader
notices side by side, and its footer swapped controls under the pointer
every time a run settled.

- One persistent composer. `@librechat/client` gains a `Composer`: the
  shared composer surface with a field, a height-holding action row, and
  the main chat send affordance. The panel renders it for the whole life
  of a run — Enter steers while the child is live, and continues the
  thread once it has settled — instead of replacing the input with a
  "Continue this chat" button at completion. Its placeholder is the
  main chat one (`Message {agent}`), not bespoke guidance copy.
- Continuations carry the draft. Child threads are view-only server-side
  (`CHILD_THREAD_READ_ONLY_ERROR`), so continuing forks into a real
  conversation; what the reader typed rides along through the same
  per-conversation draft store the composer restores from, and comes
  back in the panel if the fork fails.
- Main chat's footer metrics. Turn footers use `messageFooterClasses`
  and hold their height, so the elapsed timer is sized by the `text-xs`
  its main chat counterpart inherits rather than the panel's body size,
  and the reading leaving at completion no longer steps the thread.
- The author column matches. Turns render `MessageIcon` unconditionally,
  and the streaming dot is inset onto the header icon's axis with
  `underHeaderIcon`, as main chat insets it.
- Status indicators pin their color. Label first, dot last, so a longer
  status grows inboard and the color lands on one column in every row; a
  live row shimmers its label the way a running tool call does, and the
  dot no longer pulses against it.
- The actor picker is the agent builder's. `ControlCombobox` with agent
  avatars and search, replacing a bespoke bare `Select`.

* 🧷 fix: Keep Enter Off an Empty Field and Drop Dead Actor Rows

- The shared `Composer` never submits an empty field on Enter. Continuing
  a settled thread needs no text, so its send button stays live — but a
  stray Enter must not navigate the reader out of the panel.
- The actor picker lists only actors with a task to open. Its list has no
  disabled state, so a child without one was a row that silently did
  nothing; the selected thread stays listed regardless.
- The header glyph loses its plate, like the `MessageRow` author slot, so
  an agent avatar reads as an avatar rather than an inset badge. The send
  glyph matches main chat's at 24px.

* 🎚️ fix: Hand Continuation Text Over in Memory, Not Through Saved Drafts

Codex review of the first pass, all three findings.

- The continuation's text no longer goes through the composer draft store.
  Draft restoration is gated on the Save Drafts preference, so with drafts
  off the forked chat opened empty and the words were left sitting in local
  storage the reader had asked not to use. A new transient
  `pendingComposerTextByConvoId` carries them in memory instead, keyed by
  conversation so the handoff survives a navigation that resolves its
  record before moving the route; `useTextarea` drains it once, after the
  autosave restore it must not lose a race with.
- The draft is read when the fork lands, not when it was requested, so
  anything typed while the request was in flight travels too.
- Actors whose task id has not arrived are left out of the picker rather
  than offered as rows that cannot open anything (already in the previous
  commit; the filter keeps the selected thread listed regardless).

* ⌨️ fix: Give the Panel Composer Main Chat's Keyboard Contract

Codex round two.

- The composer honors "Press Enter to send". With the preference off a bare
  Enter is a line break and never reaches the field's action, so reaching
  for one cannot steer a live run or leave for a continued chat; ⌘/Ctrl+Enter
  submits there instead.
- The IME guard covers Safari. `isComposing` is already false on the very
  Enter that commits a candidate there, so tracked composition state and the
  legacy `Process`/229 signals stand in for it, as the main chat composer's
  own guard does.
- The actor picker renders in the panel rather than a portal. On mobile the
  panel is a modal whose focus trap only knows its own descendants, so a
  portaled search field let Tab escape to the page behind it.

* 🧭 fix: Bind the Continuation Draft, Contain Escape, Defer to the Key Policy

Codex round three.

- The continuation draft is bound to the selection that asked for it. The
  composer is shared across selections, so a fork that lands after the
  reader moved on was handing over the words now in front of them and
  clearing the composer they belong to. The live field is still preferred,
  but only while it is still that continuation's own.
- Escape is handled on the panel element instead of through the focus
  trap's callback. That callback runs on a native listener on the aside,
  so it fired BEFORE any React handler inside it and closed the whole
  panel — taking the composer's text — when a nested popover meant to
  dismiss only itself. As a React handler it bubbles in DOM order, so the
  actor picker can contain its own Escape; `ControlCombobox` gained an
  `onOpenChange` so the panel knows when that is.
- The composer defers to the host's key policy. `Composer` takes an
  optional `resolveKeyVerdict`, and the panel routes it through the same
  `resolveComposerKeyDown`/`useComposerBindings` the main chat form uses,
  so a rebound or unbound submit shortcut behaves identically here and
  chords claimed by global shortcuts are left for the window handler.

* 🔑 fix: Stop the Trap Eating Escape, Freeze the Actor Mid-Continuation

Codex round four.

- `useFocusTrap` only consumes Escape when it actually answers it. Its
  listener is native and sits on the container, so it runs before every
  React handler inside — and by calling `preventDefault` with no callback
  it swallowed the key from the host that had taken Escape back, leaving
  the panel unclosable. Both other callers pass a handler, so nothing else
  changes. The panel suite now runs the REAL trap: stubbing it is what let
  the previous Escape assertions pass in both directions.
- Nothing swaps the composer while a continuation is out. The panel holds
  the draft that travels with the fork, so the actor picker is frozen and
  the newest-task advance defers until the request settles, rather than
  leaving another actor's words to be discarded with the panel.
- The 4 KiB cap applies only in control mode. It bounds a subagent control
  command's payload; a continuation is ordinary chat text bound for a
  composer that caps nothing.

* 🪢 fix: Hold the Selection While a Failed Continuation's Words Survive

Codex round five.

The newest-task advance deferred while a continuation was in flight, but
the error path released that hold at once — so the advance fired, the
control identity changed, and the identity effect wiped the composer. The
panel stays open after a failed continuation precisely so the reader can
retry with those words, and this threw them away.

The hold now covers a failed continuation for as long as its draft is
still in the box, and releases itself the moment the draft is sent or
cleared. The test asserts both halves, and fails if the hold narrows back
to the in-flight case.

* 🧹 fix: Hold for Any Unsent Draft, and Let a Cleared One Stay Cleared

Codex round six.

- Unsent words hold the selection wherever they came from. Protecting the
  draft only once a request had started or failed left the plainest case
  open: type a continuation, let a newer task arrive, and the advance
  changed the control identity and emptied the composer. The hold is now
  `continuationPending || draft is non-empty`, which subsumes the failed
  case and lets the extra flag go.
- Emptying the field after asking to continue is the reader withdrawing
  the words. While the composer still belongs to that continuation it IS
  the draft, verbatim, so the destination no longer reinstates text they
  deleted; only a composer that has moved to another selection falls back
  to what was captured at submission.
2026-08-31 21:46:58 -04:00
Danny Avila
47dbcb4910
🎯 fix: Keep the Thinking Label on the Lightbulb's Axis While Shimmering (#15437)
The shimmer PR (#15431) nested the label inside a span carrying `.shimmer`,
which is `display: inline-block`, alongside `truncate`. An inline-block whose
overflow is not visible takes its baseline from its bottom margin edge rather
than from its last line box, so the label row's line box grew from 18px to 23px
and the text lifted 2px while the lightbulb — pinned to the row's 30px centre by
the copy button — stayed put. Every streaming reasoning header sat off its own
icon's axis until the label settled back to "Thoughts".

The sweep now rides the label row itself. That row is a flex item, so
`.shimmer`'s `inline-block` is blockified away and the text lands exactly where
the un-shimmered label lands: measured against the real component, both states
report an 18px box and the same text centre, where the nested form reported
23px and a 2px offset.

The nested span survives only for the generated-label entrance, which cannot
share an element with the sweep because both drive `animation-name`. It keeps
the entrance outside — the clipped element paints the glyphs itself, so a
descendant's opacity cannot fade them — and takes `align-top` so the baseline
rule above cannot reopen the gap there.

Found by rendering the real component in a browser; a hand-built reproduction of
the same classes did not show it, and neither did the DOM assertions, which is
why the regression shipped.
2026-08-31 21:15:28 -04:00
Danny Avila
cceb936655
🪗 feat: Fold a Live Run's Tool-Call Groups Into the Phase Card (#15434)
* 🪗 feat: Fold a live run's tool-call groups into one phase card

A long agent run renders one row per labeled tool block, so a turn that
takes twenty steps pushes twenty disclosure rows between the reader and
the answer. The parent phase card already solves this — it just does not
exist until the server has summarized the phase, which is after the run.

Give `groupActivityPhases` the whole partition instead of only the part
the server has claimed: an unclaimed run of two or more labeled activity
blocks becomes a phase segment with a client-built header. `ContentParts`
renders `ActivityPhaseGroup` for it exactly as it does for a real phase,
so both cards share one definition of the chrome, the fold animation, the
lazy body, and the approval retention — change the styling once and both
move.

The header carries the newest filled child label, which makes the
collapsed row a ticker: it reads as the line that would have been at the
bottom of the unfolded list, and the generated summary replaces it
verbatim when the marker lands. The fold ends at that newest label, so
the reasoning and tool call still streaming stay outside the card where
they can be watched, and join on the commit their own label fills.

Boundaries mirror the server's own phase rule (steer, existing marker,
substantial prose) so the synthesized span and the phase card that
replaces it cover the same content. Answer text is always a boundary —
`groupSequentialToolCalls` absorbs only `commentary`, and nothing the
reader came for may end up behind a disclosure.

Toggle memory moves into the message-wide map both cards now share with
`ToolCallGroup`, keyed on the span rather than either header, so opening
the ticker to watch survives the swap to the summary.

The rule is content-derived, so history and a live run partition
identically and nothing unfolds at settle — `activityPhaseLabel` is
opt-in, and a deployment with child labels but no phase summaries would
otherwise drop the whole list back open at the end of every turn.

Parallel content stands down: those columns own their layout and a
synthesized card would pull them onto the phase path.

* 🎞️ fix: Do not replay the entrance fold when a summary claims a fold

A server marker landing on a span a synthesized card already covers was
reading as an arrival, so the real phase card mounted with the entrance
animation — which mounts a card OPEN and folds it shut over the next two
painted frames. On a span the reader had already watched fold, that
flashes every sub-group back onto the screen before collapsing again.

Record the content indices sitting behind a synthesized card and suppress
the entrance when the marker's span overlaps them. Indices rather than
keys, so a start boundary the server resolves slightly differently from
the client still counts as the same span.

* 🙋 fix: Never fold a span holding an unresolved approval

A fold summarizes work that is done. An approval waiting on the reader is
the opposite: the run is blocked until they answer, and burying the
request behind a disclosure they have to discover strands the turn.

A server marker cannot reach this state — it is emitted after the batch
resolves — but a subagent nested inside an already-labeled group can
raise an approval long after its parent's label filled, which puts it
inside a synthesized span. Render that span exactly as it would render
without the feature until the approval clears, then fold.

* 🧪 test: Assert the fold header ticks forward without re-opening

* 🧷 fix: Tighten what a synthesized fold may claim and hide

Four issues from review, each now pinned by a test that fails without its
fix.

An error, an image, or a summary between two labeled blocks was joining
the run and disappearing into the card. Membership is now the allowlist
`groupSequentialToolCalls` uses — tool calls, reasoning, commentary,
labels — plus AGENT_UPDATE, which the server deliberately keeps inside a
phase because a transfer card cannot join a tool group. Anything else is
content in its own right and ends the fold.

Orphan labels were counting as activities. A label that claims nothing
heads no group and renders as a standalone line, so two of them are not
two activities — folding them hid the first behind a card whose body was
just the pair of lines. Labels now count only when they claim a non-empty
block, with the claim closing at every label (blank reservations
included) exactly as `claimStart` does, and handoff calls claiming
nothing.

Card identity was anchored to the span's first part, so it moved when the
server extended a phase back across short unphased text the client treats
as a boundary — dropping the reader's open card on the swap the shared
identity exists to survive. It is an ordinal now: phase cards only ever
append in content order.

The synthesized entrance is gone. It mounts a card OPEN and folds it
shut, and this component remounts mid-run — `messageId` is in the key and
changes when the placeholder hydrates to the server id, and a reconnect
mounts straight onto existing labeled content — so it would flash the
whole fold back open partway through a run. Fold GROWTH was never
animated, so formation matching it is the consistent behavior.

* 🔑 fix: Key a phase card by its span so the summary reconciles in place

Two more from review, and the first one condemns the mechanism rather
than the instance.

Ordinal card identity was not stable: an earlier server phase can hold a
pending marker that yields no segment yet — its activities are handoffs,
or no child label has filled — so a later fold takes `phase:1` and is
pushed to `phase:2` when that marker fills, handing the reader's override
to a card they never opened. Any counter has this shape, so the transfer
map is gone entirely.

What replaces it needs no state to hand over. Both card kinds now render
under one key anchored to the SPAN, so the card that carries the summary
is the same element as the fold it replaces and React reconciles it in
place — the reader's open card simply stays open. A phase with no
children has no span and keeps its marker key. `ActivityPhaseGroup` is
byte-identical to dev again: the shared component needed nothing.

The honest limit, now pinned by a test: when the server extends its span
back across short unphased text the client treats as a boundary, the two
spans differ, and a card covering different content is keyed and mounted
as a different card. The toggle does not carry there. Keeping the
stricter boundary is deliberate — `phase` is only stamped by providers
that expose it, so matching the server would let a short final answer
hide inside a fold.

Second: `tool → transfer_to_* → label` was counting as a claimed batch,
while the grouping flushes the block at the non-groupable transfer and
then drops the label through `coversTransferCall`. Two such batches built
a card over labels that render nowhere. The claim now resets at any part
the block cannot hold, matching the flush.

* 🗝️ fix: Drop messageId from the card key and let a grown span animate

Two more from review; the second deletes a mechanism the span-keying had
already made redundant.

The assistant streams under a synthetic `${userMessage.messageId}_` for
the WHOLE run and only takes its server id at `finalHandler`, so a key
carrying `messageId` remounted every phase card at settle and dropped
whatever the reader had open — a guaranteed loss at the end of every
turn, not a narrow window. The card key is the span alone now, which is
how `ToolCallGroup` has always keyed for the same reason.

`replacesFold` suppressed the entrance whenever a marker's span merely
OVERLAPPED a previous fold. When the server extends its span back across
short unphased text the two spans differ, the card is genuinely a new
one, and suppressing the entrance made the text the reader was looking at
blink out of existence instead of being folded in. The suppression is
gone: a same-span replacement is now reconciled rather than remounted, so
it cannot animate anyway, and that was the only case worth suppressing.

With it goes the `folded` index tracking it was the sole reader of.
Anchoring identity to the span turned out to subsume both the expansion
map and this set — the diff is smaller than it was two rounds ago.

* 👥 fix: Separate a sibling switch from a message taking its server id

Dropping messageId from the card key fixed settle but opened the case it
had been guarding: MultiMessage renders siblings without a key and reuses
this instance, so an index-anchored card carried one response's open
state into an unrelated one.

Neither prop tells the two apart on its own — setIsSubmitting(false) is
batched with the final message swap, so that commit looks exactly like a
sibling switch by message identity. `siblingIdx` does: it moves when the
reader pages to another response and holds still when the same response
takes its real id.

* 🪪 fix: Pair the card's position with its first provider id

`siblingIdx` was the wrong discriminator. MultiMessage recomputes the
viewed response's positional index whenever a sibling is added or dropped
around it — its own docblock calls this out — so a key carrying it
remounted the card during churn that deliberately PRESERVES the response
the reader is on. Its docblock also records why no message id works
either: messageId and parentMessageId both move through the SSE
lifecycle, which is exactly why that component reconciles positionally.

The span's own position was never the problem — it is content-derived, so
churn cannot touch it. It only failed to separate two siblings whose
cards start at the same index. Pairing it with the first tool call's
provider id fixes that and nothing else: settle and churn leave both
halves alone, and the pair also keeps repeated provider ids in one
message apart, which a bare id would have collided on — the existing
`counts repeated legacy provider-id groups` case proved that immediately.

* 🧲 fix: Anchor the card to its first tool call and spare hoisted output

Anchoring the key to the span's first PART broke at settle whenever a
phase opened with an empty TEXT or THINK: preserveStreamedContentIdentity
drops those slots and stamps the survivor, so the anchor moved and the
card remounted exactly when the run finished. `getToolGroupId` already
records the answer — the tool calls themselves do not move — so the
anchor is the first tool call's own position now, with its provider id
still alongside to keep repeated ids apart.

That also closes the limitation this PR had been documenting. A phase the
server extends back across short unphased text still shares its first
tool call with the fold, so the two now reconcile and the reader's open
card rides through. The test that pinned the old degradation asserts the
better behavior instead.

Second: `ToolCallGroup` renders `groupAttachments` OUTSIDE its own panel
so a generated image or file survives collapsing the group. Folding that
group into a card put the hoist back inside a disclosure and the output
disappeared. These attachments are message-level and never appear in
`segment.content`, so the visible-part allowlist could not catch them —
a span carrying any takes the same escape as an unresolved approval and
renders unfolded. Hoisting them to the card would be better and would fix
the same hole for server phases on dev; it is not this PR.

* 🎯 fix: Give id-less spans a scope and stop the second streaming cursor

Two from review.

The card key's no-provider-id fallback was a bare position, which cannot
separate two siblings whose cards start at the same index — the exact
hole the provider id had just closed, reopened for id-less legacy calls.
That branch carries the message-change counter now, which is what
`getToolGroupId` does for its own id-less fallback, and it accepts the
same tradeoff: the counter advances at settle, so those cards remount
then. Spans that have a provider id are unaffected.

`showEmptyCursor` never consulted `isLast`, so a trailing empty provider
slot rendered the initial waiting state from inside its own segment even
though a card was already carrying the cursor — two dots at once. Reusing
`isLast` would have been wrong: it means "leaf of the tree", and the
existing thinking-dot tests legitimately leave it false while submitting.
The phase branch now computes which card owns the cursor and passes that
down, alongside the other internal segment signals. It covers real phase
markers too, which had the same duplication.
2026-08-31 21:14:20 -04:00
Marco Beretta
146664e0d1
🔐 fix: Redirect OAuth Browser Navigations Instead of Rendering JSON (#15440)
* 🔐 fix: Redirect OAuth Browser Navigations Instead of Rendering JSON

The /oauth/* routes are reached by top-level browser navigation, so a
middleware that rejects one with a JSON body makes the browser render
that body as the page. Hitting the login rate limit during a social
sign-in dropped the user on a blank page showing:

  {"message": "Too many login attempts, please try again after 5 minutes."}

Mark those requests with markOAuthNavigation and have the two middlewares
that can reject them answer with a redirect to the login page carrying an
error code the client localizes. The login limiter stays a single instance
so the attempt budget is still shared with POST /api/auth/login, and that
route keeps its JSON 429 for the SPA.

* 🧪 fix: Address PR Review Bot Findings

chatgpt-codex-connector:
- Split the inline `type TranslationKeys` specifier out of the value
  import, per the standalone `import type` convention in CLAUDE.md. This
  also clears the Static checks import-sort failure.
- Cover the new OAuth error branches in Login.spec.tsx: each mapped
  `?error=` code raises its localized toast and the query is cleaned,
  and unmapped codes including prototype members raise nothing.
2026-08-31 21:13:47 -04:00
Danny Avila
49e1e32f3a
🧺 fix: Funnel MCP Catalog Work Through a Shared Gate (#15429)
* 🧯 fix: Bound MCP Catalog Work Across Requests

* 🛑 fix: Reject Saturated MCP Discovery

* fix: fairly bound MCP catalog work

* test: type catalog snapshot mock arguments

* test: assert snapshot deadline forwarding

* fix: harden MCP catalog scheduling
2026-08-31 14:13:31 -04:00
Danny Avila
2e2c29e10d
🫀 fix: Expire Aborted Background Tool Leases (#15428)
* fix: Bound Background Tool Producer Heartbeats

* fix: fence timed-out delivery retirement
2026-08-31 14:13:00 -04:00
Danny Avila
6be3f798fc
🪩 style: Shimmer Live Reasoning Labels and Timers (#15431)
* ✨ style: Shimmer the Thinking Label and Elapsed Timer While Generating

The label above streaming reasoning and the elapsed-time reading beneath a
streaming response both sat static while every other in-flight indicator on
the row — a running tool call's label, a running skill, a pending question —
carried the shimmer. Give them the same treatment so "in flight" reads the
same way everywhere.

Both are gated on state that is already established:

- `Reasoning` shimmers only while `effectiveIsSubmitting && isLast`. Settled
  thoughts, an older row the reader paged to, and reasoning overtaken by
  later content all keep the plain label. The legacy `Thinking` component
  and `ThinkingLabel` (the detached-subagent marker) do not opt in.
- `Elapsed` only ever renders under the streaming latest row at the newest
  sibling position (`shouldShowElapsed`), so there is no settled reading for
  the animation to misrepresent. Completed tool-call durations go through
  `getRunStepDurationLabels` and are untouched.

`.shimmer` and Tailwind's `animate-in` entrance both drive `animation-name`,
and `.shimmer` — plain CSS after `@tailwind utilities`, equal specificity —
would win and silently kill the generated-label slide-in. The two therefore
sit on nested spans, in the order that is safe for `background-clip: text`:
the transform on the ancestor, the clipped background on the descendant.

* 🎨 fix: Drive the Shimmer From Theme Roles and Honor Reduced Motion

Two review findings, both against the shared `.shimmer` utility rather than
this PR's call sites — but this PR adds two more consumers of it, one of which
sweeps continuously for the whole generation, so they land here.

Theme roles. The gradient hard-coded near-black and near-white and selected
the second with `.dark`, so a stored or custom theme that restated its text
colors got a shimmer that ignored them. Themes are applied as bare RGB
triplets on the document element; the sweep now reads `--shimmer-base` /
`--shimmer-dip` (with the established `-alpha` companions the border roles
already use) and falls back to `--text-primary` when a theme states no base of
its own. The fallback is resolved at the label, not in the `:root`
declaration — `var()` substitutes where it is declared, so seeding
`--shimmer-base: var(--text-primary)` on `html` would have frozen the default
and ignored every theme scope applied further down. Dark still declares its
base outright: that value is dimmer than the dark theme's text color, and
restating it as `--text-primary` would have brightened every shimmer.

Default light and dark are preserved: rendered old against new at a frozen
sweep position, 144 of 137,280 pixels differ by a single 1/255 channel step,
which is the alpha-syntax rounding path. A theme that sets only
`--text-primary` now colors the sweep with no shimmer-specific override.

Reduced motion. `.shimmer` was the one custom animation in `style.css` with no
`prefers-reduced-motion` guard — `.t-icon-swap`, `.t-avatar`,
`.scroll-animation-*` and `[data-lc-fade]` all carry one. Stopping the
animation alone is not a fallback: the background settles at position 0 and
shows only half the sweep, stranding the tail of the label in the
near-invisible dip. The guard drops the gradient and hands the glyphs back to
the color they would otherwise have had.

* 🎨 fix: Register the Shimmer Stops and Keep the Sweep Whole

Codex round 2, on the previous round's fixes.

Register the stops. Moving the constants into CSS variables did not make them
theme-owned: `applyResolvedTheme` writes only `themeColorTokens`, so nothing a
theme declares could reach `--shimmer-base`. Dark was the case that mattered —
`style.css` declares a `.dark` base outright, which a theme can only outrank by
having the property applied to the document element — so a custom dark theme
still got the stock white sweep. `rgb-shimmer-base` and `rgb-shimmer-dip` now
sit in the registry beside the text roles, seeded with today's values in both
modes; `registry.spec` already enforces light/dark key parity. The opacities
stay in CSS as `--shimmer-*-alpha`, the way the border roles keep
`--border-*-alpha`.

This narrows the earlier claim: a *versioned* theme resolves every default, so
it now states its own sweep rather than inheriting `--text-primary`. The
`--text-primary` fallback still serves plain CSS and legacy partial `themeRGB`
themes, which apply only the keys they name.

Keep the sweep whole. `key` remounts the label row so the entrance replays,
restarting the sweep with it; reasoning labels revise on a 3s default
(`DEFAULT_UPDATE_INTERVAL_MS`) against a 4s sweep, so it never completes a
cycle. Keying only when an entrance will actually play leaves an un-animated
label — smooth streaming off, or reduced motion — with a stable identity and an
unbroken sweep, instead of a bare snap with nothing to cover it.

Not adopted: keeping the shimmer element stable and replaying the entrance
inside it. The clipped element paints the glyphs itself, so a descendant's
opacity cannot fade them — rendered at entrance t=0, the current nesting is
correctly invisible while the swapped one sits fully lit through its own fade.
The remaining restart happens only underneath a deliberate entrance animation.

* 🎨 fix: Carry Legacy Themes Onto the Registered Shimmer Stops

Codex round 3. Registering the stops migrated versioned themes but not stored
or environment `themeRGB` ones: `ThemeProvider` routes those to the legacy
`applyTheme` adapter, which bypasses `resolveTheme` and applies only the keys a
theme names. A stored theme has no shimmer keys, so it kept the stock sweep
while the rest of its palette moved — and in dark the CSS cannot recover on its
own, because `.dark` declares a base outright and the `--text-primary` fallback
never runs.

`mapColors` already carries legacy themes across a token they predate:
`--surface-composer-hover` derives from `rgb-surface-hover` when absent. The
shimmer base follows the same shape, deriving from `rgb-text-primary` — which
is what it already resolves to in light, so only dark changes. The dip has no
legacy counterpart and stays at its default; it is the faded half of the sweep,
carried at low alpha, so it reads as dimmed against any base.

Not a regression being fixed but an improvement being extended: before this
branch, every theme got the same hard-coded sweep. This is the last path that
still did.

* 🎨 fix: Mirror the Shimmer Fallback Onto Versioned Theme Resolution

Codex round 4. The compat fallback added last round covered only the legacy
adapter, but this codebase carries these fallbacks on both paths: the
`rgb-surface-composer-hover` derivation exists in `mapColors` AND in
`resolveTheme`. Adding the shimmer stop to just one left the halves disagreeing.

The versioned path is the one where the omission actually hurts. A definition
written before these keys existed cannot name them, so `resolveTheme` filled
the gap from the bundled base and pinned that theme's in-flight labels to
LibreChat's own sweep — a theme restating its text as white would light every
label in the stock near-black. Omission there means "not written yet", not
"wants the bundled sweep"; a theme that does want it still says so by naming
the stop, exactly as it opts out of the composer-hover fallback.

Both paths now behave the same: a theme that restates its text and names no
shimmer base gets its sweep from that text color, whichever way it is applied.
2026-08-31 14:12:39 -04:00
Danny Avila
98eb3f8b1c
🧿 fix: Restore Shared IP Limits for Generation Retries (#15427)
* fix: Preserve IP Limits for Generation Retries

* test: Cover Scheduled Retry IP Limiting

* style: format retry limiter route
2026-08-31 14:11:12 -04:00
Danny Avila
982a9a5141
🧴 fix: Strip LangChain URLs From Provider Errors (#15421)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🧹 fix: Strip LangChain Attribution From User-Facing Provider Errors

LangChain classifies provider failures by mutating `error.message` with a
docs URL, and that message is forwarded verbatim into the error content part
we persist, so users saw `docs.langchain.com/...` credited for a provider's
own 429. The client only intercepted `MODEL_NOT_FOUND`, leaving every other
code to render raw.

Resolve the classification server-side instead: codes with localized copy
become the typed payload the client already knows how to localize, and every
other message keeps the provider's wording with the docs URL removed before
it reaches the database. The untouched error still reaches the logs through
`getSafeErrorMetadata`.

The client keeps a code-driven lookup for messages persisted before this, and
gains `com_error_model_rate_limit` alongside the existing not-found copy. The
OpenAI-compatible and Responses controllers now share one provider-error
helper rather than three copies of it, so the strip applies on those surfaces
too.

* 🛡️ fix: Harden Error Sanitization For Non-String Messages And Legacy Text

Codex review follow-up.

`stripLangChainTroubleshootingUrl` assumed a string, so an error-like object
with a truthy non-string `message` threw `.replace is not a function` inside
the error handler that was meant to report it — the template interpolation it
replaced had coerced such values safely. It now coerces at the boundary, as
does the code lookup.

Legacy messages whose LangChain code has no localized copy still rendered the
docs URL, which is exactly the historical case the client-side path exists to
clean up. The rendered fallback is now stripped too.

Both helpers move to `librechat-data-provider`, which is shared by the client
and the backend, so one implementation serves both instead of a second regex
drifting in the component.
2026-08-31 12:51:34 -04:00
Danny Avila
f0f82ec5fa
🧷 fix: Fence Server-Admitted Queue Boundaries (#15393)
* fix: fence server-admitted queue boundaries

* fix: preserve effective queue admission boundaries

* fix: fence queue recovery predecessor

* fix: unify queued boundary reconciliation

* fix: make queued turn receipt settlement monotonic

* fix: narrow queued receipt projection status

* fix: close queued turn settlement races

* fix: make queued admission lineage clock independent

* fix: centralize queued turn admission ownership

* test: type queued admission outcomes

* fix: make queued admission identity durable

* fix: carry queued admission identity end to end

* style: sort queued turn test imports

* fix: unify indeterminate admission ownership

* fix: migrate queued admission fence state

* fix: migrate every queued admission owner

* fix: surface queued admission reconciliation

* fix: preserve queued receipt evidence

* test: type queued receipt regression

* fix: stabilize queued receipt authority

* fix: supersede initial receipt fetches
2026-08-31 12:31:07 -04:00