`{ virtual: true }` is for modules with no file on disk, and `@librechat/client`
is a real workspace package. Registering a real module virtually let resolution
fall through to the package itself often enough to fail roughly one full-suite
run in three: the dialog then rendered through its Radix portal, putting the
file input outside the container the helper read, and `useToastContext` handed
back the real context so the size-rejection assertion watched a spy nothing
called. Both symptoms were the same mock going missing.
Drop the flag and read the input from the document, which stays correct whether
or not the stub is the module that resolves. Eight consecutive full runs clean.
The three keys lost their last consumers when the escalation UI moved onto the
queue rail and the pending steers: the two toggle labels belonged to the
overflow menu the rail replaced, and the enable hint to a settings row that no
longer explains itself that way.
UploadSkillDialog's helper read the file input out of the container `render`
returns, which only holds it while the `@librechat/client` stub is the module
actually resolved — the real OGDialogContent renders through a Radix portal
into the body, so the lookup found nothing and the suite failed roughly one run
in three under load. Query the document instead, which is correct either way.
isSubmitting flips the moment the user sends, but the start POST installs the
generation epoch a beat later. Through that window steering, send-now and
interrupt all refuse, while the composer advertised them anyway: the hint read
"Enter queues · Ctrl+Enter send now · Alt+Enter interrupt & send" when only the
queue half worked, and the send slot showed the ordinary disabled send button
because it keyed off showStopButton, which the epoch also gates.
Give the hint canControlGeneration and let it name only the default action
until the run is reachable; queueing is local, so that part is honest
throughout. Key the send slot on whether the during-run slot rendered anything
instead of on showStopButton, so the control that queues is present exactly
when Enter queues. The slot already decides between the during-run button,
Stop, and nothing, so an empty one still falls through to send.
The queue rail's escalation control had the same shape of problem: it was
gated on canSteer, so it vanished for the window after the drain starts the
next run and reappeared once that epoch landed. Show it for the whole run and
disable it whenever steering cannot reach one, which also covers the approval
pause it already handled.
Found by watching a real run: the window is normally ~250ms, but any failure
that keeps the epoch from arriving strands the composer in it.
The rebase onto dev dropped the four steering components dev had just added
escalation to, so the arm endpoint, the escalateSteer shortcut and its
translation keys all survived with nothing to act on: the shortcut resolved
and found no target.
Port the feature onto the surfaces that replaced those components rather than
reinstating them. The arm logic moves into useSteerEscalate, so the queue rail
and the in-thread pending steers escalate through identical race rules instead
of two approximations: one idempotent op flips preempt on the existing queued
item, a lost response is retried once, and every "too late" interleaving stays
an honest armed: false.
EscalateNowButton carries the shared shortcut wiring, so hovering or focusing
a row aims escalateSteer at that exact message. Both surfaces enforce the
single-interrupt invariant through escalatingSteerFamily, which covers an arm's
round trip before its own chip can report preempt.
The queued row keeps the control visible-but-disabled while paused on an
approval, which is when cutting the reply short is most wanted. The in-thread
row offers it only on an acknowledged steer: one still sending has no server id
to arm, and one already interrupting has nothing left to escalate.
Dev's "always interrupt instead" overflow toggle is deliberately not restored.
It is a global preference, and the redesign moved those to Settings, where
"Steering interrupts generation" already lives; the row keeps only what acts on
that message.
The rebase onto dev removed PendingSteerChips, so the note explaining why
the returned object is memoized named a component that no longer exists.
Bar and Queue are the memoized consumers that now depend on it.
Ctrl/Cmd+Enter did the same thing as a bare Enter whenever Enter-to-send
was on, so the chord was spent duplicating a key that was already bound
and users had only Shift+Enter to break a line.
Read the modifier as the inverse of plain Enter instead: it sends when
Enter writes a newline, and writes one when Enter sends. The rebound
path in resolveSubmitOverrideAction already resolved it this way, so
this only brings the default path in line with it.
The during-run table claims Ctrl/Cmd+Enter for the alternate send action
and resolves before the idle tail, so steering, queueing and interrupting
are untouched. Answer mode hands the newline chords to the normal path so
the free-form answer box breaks lines like the composer; Enter on a
highlighted option still confirms the selection.
Closes#12030
Putting a message back into the composer is the one path here that can destroy
text outright: the reclaimed-steer restore reports whether it took the words,
and a `true` it has not earned makes the caller drop them. Every condition it
weighs is about time passing between a click and its answer — a draft typed
since, a file staged since, a chat navigated away from, a question pause that
began, the composer unmounting — which is precisely what a component holding a
dozen other concerns makes untestable.
Moved verbatim to `useComposerRestore` alongside the unguarded `editToComposer`
it delegates to, and covered: thirteen cases, one per refusal and one per way
the guard must NOT refuse. ChatForm drops 120 lines.
Also removes ten translation keys left behind by the composer rebuild.
The composer palette derives its whole catalog from the badge-row context, and
`useToolToggle` returned a fresh object every render, so memoizing that context
value changed nothing: six new identities per render kept it invalidating on
every keystroke. Memoize the hook's own return instead.
Also removes four steer helpers, the run-end map they read and the drain re-arm
they called, none of which have had a caller since the pending steers moved into
the thread; tells the user why a queued message would not come back to an
occupied composer instead of leaving the button looking broken; and refuses a
stop once the take is already being transcribed, where it could only rewrite how
a committed take gets spent.
Dictation only ever submitted through the speech engines' auto-send
callback, which they never fire with the default setting: stop-and-send
left the transcript sitting in the composer, and a plain stop stopped
honouring Auto Send Text at all. The send is now armed on the stop and
spent once the take settles, with a per-take guard so the setting and
the button cannot both spend it. Cancel also keeps the draft on its ref
until a take is really spent, so an external transcription that was
already in flight can no longer wipe the draft it just restored.
Assistants get their unfiltered picker back — the provider check does
not recognise them, so the palette had scoped them to images and dropped
PDF support. Memory honours the user's personalization opt-out. Quote
chips are gated on the same flag as the quote button, so an endpoint
that cannot transmit an excerpt no longer shows one as staged.
The upload-file shortcut targets the palette disclosure that replaced
the attach menu, and Open Files opens the file manager dialog now that
the side panel link is gone.
Retried steers resolve "is the run over" from the conversation's
submitting state instead of the block's unmount: navigating away from a
live run unmounts it too, and queueing there sent the same words twice.
Composer hints follow the Enter-to-send setting rather than always
naming Enter as the send key.
Send now fell back to the message the row was rendered with, so a click
landing just after the drain sent the same words twice. An admin override
replaced a reasoning parameter instead of merging into it, dropping the
options the control needs to render at all. The landing screen released
its lift only when the popup closed, not when the composer unmounted.
Chip widths move into state, concurrent steer retries each keep their own
frame handle, and starting a stream is wrapped so a throw cannot leave the
composer stuck generating.
Trashing a queued message removed it even when the composer refused to
take the text back, losing it. Escape during a drag left the reorder
applied, since rows move as the pointer crosses them. The palette had its
own copy of the favourites hook and lost the cap toast with it.
Also removes the cancel and reclaim hooks, which lost their consumer with
the in-flight steer menu, and covers the queue actions, the picker's tool
routing, useAttachExisting and the skill scoping gate.
The virtualized list added its own grid role and tab stop between the
listbox and its options, and the highlight followed a position rather than
a row, so starring one left aria-activedescendant naming a header. Hidden
chips stayed tabbable during a recording, the effort track answered no
arrow keys, and dictation was never announced.
The tool catalog was also rebuilt on every keystroke, from unstable
capabilities, an unmemoized context value and a row model derived while
the palette was closed. Every dictation take leaked an audio context.
Converting a steer sorted the whole queue by creation time, undoing a
reorder made from the rail and changing which message sent next. Each
converted item is placed at its own position instead.
Retries resolved through mutate callbacks, which react-query drops once
the observer unmounts. The pending block unmounts when the run ends, so a
chip could sit on "sending" for the rest of the conversation.
The redesign deleted the attach menu specs along with their components,
but that logic moved into hooks and went untested, which is how the code
environment upload regressed. Restores those cases and adds the palette
row model, the entries it is offered, and chip packing.
The rail is the send order, and it was fixed at the moment each message
was written. Rows carry a handle now; the keys work without a pointer and
announce the new position.
React re-inserts every row a starred entry passes, and a re-inserted node
drops the transition it was about to run, so starring jumped while
unstarring animated. The move is played after the redraw instead.
Starring a row rearranged the list without telling react-virtualized to
re-measure, so every row below it was drawn at the height of whatever used
to sit at that index until the palette was reopened.
Only the provider row stays out. Text, file search, code environment and
the SharePoint mirrors sit behind one row that opens in place. Searching
still reaches all of them by name.
They were only reachable by dropping a file on the composer. The menu and
the drop router now resolve the same allowances, so an ordinary chat can
upload to file search or the code environment.
The abort listener also fires on navigating away while the run continues
server side. Sweeping pending chips there stranded a steer the server had
already acked and sent it a second time. selectLocalSteersForQueue takes
a statuses argument so abort sweeps failed only.
Four useSteering tests went tautological once the default flipped to
queue. Seeding duringRunDefaultAction keeps them covering the guard they
were written for.
Replaces the badge row, attach menu and tools gear with a single
searchable palette: tools, skills, MCP servers and recent files, with
favourites persisted server side. Active tools become removable chips
that pack widest first and wrap to their own row when they need to.
Also adds the thinking effort slider, rebuilds dictation with a waveform
and a real cancel, and drops AudioRecorder now that the bar covers it.
* 🪺 fix: Keep Preempt-Abandoned Siblings Nested, Make Message Tree Order-Robust
* 🔗 fix: Sever Cycle Back-Edges So the Returned Message Tree Is Acyclic
* 🧪 fix: Satisfy TFile in buildTree Spec fileMap Fixture
* 🌲 fix: Assert Repaired Trees in convoStructure Specs, Uncharge Self-Parent Edges
* 📌 feat: Identity-Stable Sibling Selection Across Background Tree Churn
* 🎭 test: E2E Coverage for Thread Fold and Sibling Selection Invariants
* 🔑 fix: Treat Newest-Sibling Re-Key as Hydration, Not a New Branch
* 🧭 fix: Rebind Sibling Selection Per Parent, Detect Appends by Membership
The lazy BookmarkNav's Suspense resolution commits during waitFor's
polling, outside any act scope, so its follow-up render work lands in
React's real scheduler as a macrotask. The single empty async act added
in #14071 only drains microtasks and the act queue, so on slow Windows
shards that work can still be pending when baselines are captured. The
next act flushes pending root work wholesale, so the first stream tick
carries the leftover pass and inflates the tag counter (Expected: 1,
Received: 2). Flush full event-loop turns inside act until two
consecutive turns add no renders, then capture baselines.
* 💬 feat: Interim Progress Card for Streaming ask_user_question Calls
* 🔍 fix: Match Progress Card Against Every Live Ask Pause, Not Newest Only
* ⏳ feat: Hold Streaming Cursor Under Answered Question While Resume Is In Flight
* 📦 chore: Upgrade react-router-dom to v7.18.2 (security)
Fixes GHSA-wrjc-x8rr-h8h6 (open redirect via backslash in Link/useNavigate,
CVE-2025-68470 bypass) and GHSA-337j-9hxr-rhxg (deserializeErrors constructor
injection). Neither has a 6.x patch; v7's react-router-dom is a shim
re-exporting react-router, so all existing imports work unchanged.
- vite manualChunks: match react-router so the routing chunk still captures
the router (v7 moves all code out of the react-router-dom package)
- jest: add test/polyfills.js (TextEncoder/TextDecoder + minimal Request);
v7's CJS bundle constructs TextEncoder at module scope and builds a Request
per navigation, neither exists in jsdom
- auth specs: v7 types drop the synthetic default export; use a namespace
import and mark the mock factory __esModule so the useOutletContext spy
patches the object components actually read
- isSafeRedirect: reject backslashes as defense in depth for the same
open-redirect class the router patch addresses
* 📦 chore: Regenerate stale bun.lock
bun.lock predated months of package.json drift and still pinned
react-router 6.30.3. Regenerated with bun install --lockfile-only so bun
installs match current manifests, including react-router 7.18.2.
* 🗂️ fix: Commit project-chip URL updates synchronously under router v7
v7 wraps router state updates in React.startTransition unconditionally, so
the chip's paired updates tear: the conversation draft (Recoil) commits
synchronously while the ?projectId removal defers. ChatRoute's
draftProjectMismatch re-init sees draft != URL in that window and restores
the removed project. The flushSync navigate option commits both in one pass,
matching v6 ordering. Caught by the projects e2e specs.
* 🧹 chore: Drop unused banner-query spy variable in Registration spec
Pre-existing warning, but the changed-files eslint gate runs with
--max-warnings=0 so it blocks this PR. The spy call stays; only the
never-read variable goes.
* 🔗 fix: Normalize MCP Tool Keys at Every Producer, Resolve Raw Names via Aliases
Tool keys had two spellings that could diverge for any server whose name
contains characters outside [a-zA-Z0-9_.-]: the tool cache (and registry
inspector) built keys with the RAW server name, while runtime instances
are named with normalizeServerName(serverName). Three code comments
already asserted "tool keys embed the normalized server name" - no
producer honored it. For a special-character server that meant:
- definitions-only mode shipped raw def names the model echoed back,
but the executor's tool map held the normalized instance name, so
every call failed with "Tool not found";
- per-tool tool_options (defer_loading / allowed_callers /
run_in_background / describe_intent) were persisted under raw keys
that never matched the definition names the option passes resolve
against, so builder settings were silently inert;
- tool-key parsing against normalized candidate lists silently fell
back to last-delimiter splitting, which mis-parses delimiter-bearing
tool names.
The reconciliation is one contract enforced in three moves:
1. PRODUCERS NORMALIZE. The tool cache (packages/api/src/mcp/tools.ts)
and the registry inspector build keys with the normalized server
name, matching the instance names MCP.js has always assigned. The
builder's tool ids, agent.tools entries, tool_options keys, and
definition names all flow from these keys, so every model-facing
name now agrees. The cache STORE stays keyed by the raw config name.
2. CONFIG LOOKUPS RESOLVE ALIASES. New shared helpers in data-provider
(buildServerNameAliases, normalizeMCPToolKey) map a parsed
normalized name back to the raw config name that the registry,
config maps, tool cache, and plugin-auth rows are keyed by. Applied
in the definitions loader closure, handleTools grouping,
createMCPTool's parsing fallback, getUserMCPAuthMap, and the MCP
tools endpoint - matching both spellings so legacy raw keys keep
resolving.
3. LEGACY DATA HEALS AT ONE BOUNDARY. initializeAgent rewrites
raw-keyed agent.tools entries and tool_options keys to the
normalized form (normalizeAgentToolKeys) before anything consumes
them, so agents persisted under the old convention load their tools
AND have all four per-tool options honored. Placeholder and
server-pin tokens stay raw - they are config-identity references,
not model-facing names.
Servers whose names are already in the safe character set (the common
case) produce byte-identical keys before and after; the fast path
allocates nothing. Stale Redis-cached raw keys self-heal via the
existing reconnect-on-missing path within one cache cycle.
* 🧯 fix: Deterministic Alias Collisions + Raw Names in Definition Metadata
Two review findings on the normalization contract:
- Two configured server names that normalize to the same segment (e.g.
'Sales Force' and 'Sales:Force' -> 'Sales_Force') produce inherently
ambiguous tool keys; the alias map silently resolved last-wins, so a
tool selected from one server could execute against the other's
config. buildServerNameAliases now resolves collisions to the FIRST
configured name deterministically, and resolveMCPServerContext warns
once per colliding pair per process so the operator can rename one
server. A collision-resistant identifier would change every existing
tool key, so detection + stable routing is the right treatment here;
startup-time config validation can follow separately.
- The definitions loader resolved parsed (normalized) server names to
raw only inside the ToolService closure, while the definition
metadata (serverName -> mcpRawServerName) kept the normalized value.
Server instructions are keyed by raw config names, so a
special-character server's instructions were silently omitted in
definitions-only mode. loadToolDefinitions now takes rawServerNames,
resolves the boundary against both spellings, and stores the RAW
name in definition metadata - consistent with the instance path.
* 🧯 fix: Heal Stale Caches, Skill Allowed-Tools, and Builder Selectors
Three review findings on the normalization rollout, all in the
transition class:
- Stale cache entries (P1): the definitions-only loader treats the
per-server tool map as authoritative and never reconnects on a
per-key miss, so a pre-change raw-keyed Redis entry would make a
special-character server's tools vanish for up to the cache TTL.
getMCPServerTools now heals legacy raw-keyed entries to the
normalized format at read time (keys and function names), covering
every consumer with no coordinated invalidation; safe names return
the map untouched.
- Skill allowed-tools: a skill declaring a raw MCP key in
allowed-tools bypassed the initialize-boundary heal (the union runs
after it) and would neither dedupe against healed agent tools nor
match the normalized tool map. The primes' allowedTools now pass
through the same normalizeAgentToolKeys heal before unioning.
- Builder selectors: matchesMcpServer and useVisibleTools parsed tool
ids against raw server names only, so an attached special-character
server rendered as an unselected orphan card. Both now accept the
normalized spelling and resolve it back to the raw map key, keeping
legacy raw ids working.
* 🧯 fix: Fail Closed on Normalized Server-Name Collisions
Escalation of the collision finding: a deterministic first-wins alias
plus a warning still let the tools listing publish BOTH colliding
servers, so a tool selected under the shadowed second server would
silently execute against the first server's configuration (their
model-facing keys are identical, so routing cannot ever distinguish
them).
- findShadowedServerNames identifies later-configured names whose
normalized form an earlier different name claimed.
- getMCPTools excludes shadowed servers from the published listing
entirely (with a warn naming the collision), so their tools are
never selectable - nothing ambiguous can be picked.
- Server creation reserves both spellings: a generated slug may not
collide with a raw config name OR the normalized form its tool keys
would carry.
Collision-resistant model-facing IDs remain out of scope: changing
normalizeServerName's output would rewrite every existing tool key
(agent documents, caches, instance names) for ALL servers to handle a
misconfiguration that is now blocked from exposure instead.
* ✅ fix: Dedupe Reserved Server-Name Spellings at Creation
The reservation list appended normalized forms unconditionally, which
duplicated every safe name (raw === normalized) and broke the
route-level contract test pinning the exact list. Dedupe via a Set so
safe names contribute one entry, while special-character names still
reserve both spellings; adds the special-character reservation case.
* 🧯 fix: Never Heal a Shadowed Server's Keys; Align Authorization Tie-Break
Persisted references were the remaining collision vector: an agent or
skill saved with the shadowed later server's raw key was HEALED into
the shared normalized key, authorized through a last-wins map, and
routed first-wins - authorized as one server, executed as another.
- normalizeAgentToolKeys now refuses to rewrite keys of shadowed
servers (findShadowedServerNames): rewriting would produce exactly
the first server's key. Left raw, the key cannot match the
normalized-keyed tool map and the tool fails visibly - broken beats
misrouted. Covers agent.tools, tool_options, and skill
allowed-tools through the shared heal.
- filterAuthorizedTools (agents/v1.js) builds its normalized-to-raw
map via the shared buildServerNameAliases instead of a last-wins
Map constructor, so authorization resolves a colliding key to the
SAME first server execution routes to.
* 🧯 fix: Direct Identity Wins Over Aliases; Heal Client Forms and Degraded Contexts
Four review findings on the normalization edges:
- Alias hijack (P1): a user-DB server named exactly like an operator
server's normalized form ('foo' vs YAML 'foo!') had its tools
rerouted to the operator server by unconditional alias resolution.
Resolution is now DIRECT-FIRST everywhere: the parsed name is tried
as-is, and only when nothing resolves is it treated as a normalized
spelling (definitions loader, handleTools grouping, createMCPTool
fallback). buildServerNameAliases seats identity entries before
derived ones so a literal name owns its slot regardless of config
order, findShadowedServerNames and the collision warning derive from
the same construction, and getUserMCPAuthMap fetches auth under both
spellings so either owner finds its rows.
- Builder double-match: a normalized name containing the delimiter
('foo mcp bar' -> 'foo_mcp_bar') also suffix-matched a server named
'bar', selecting both cards and making removal strip the wrong tool.
matchesMcpServer now resolves the token ONCE against the full
configured list (longest boundary, both spellings) when the caller
supplies it; selection and removal share the resolution.
- Builder legacy ids: an agent saved with raw-keyed ids showed its
tools unchecked while the runtime heal kept them active, and
selection updates never replaced the legacy entries. McpSection maps
legacy raw ids to their current normalized ids when deriving and
rewriting this server's selection.
- Degraded context: a transient ensureConfigServers failure returned
an entirely empty context, leaving normalized keys unresolvable for
the request. resolveMCPServerContext now keeps the name lists (they
derive from the config snapshot alone) and degrades only the
lazy-init configs.
* 🧯 fix: Collision Detection Sees Accessible Servers; Shadowed Refs Fail Closed End to End
Round follow-ups on the collision design, all in the
DB-server-visibility class:
- The legacy-key heal detected collisions against operator-config
names only, so healing could still produce a key that direct-first
resolution routes to an invisible user-DB server. initializeAgent
gains an optional getAccessibleMcpServerNames dep (wired through
ToolService for controllers that mock it, directly elsewhere),
consulted ONLY when a configured name needs normalization - zero
cost for safe-name deployments. The heal then sees the full
accessible set and skips shadowed servers' keys.
- Wildcard and legacy raw tokens bypassed catalog filtering, letting a
shadowed server's instances join a run under the same normalized
names as the winner's. filterAuthorizedTools rejects tools of
shadowed servers at authorization (its merged map sees DB + config),
and handleTools skips them at execution.
- The builder migrated only tool selection, not tool_options: legacy
raw option keys showed disabled while the runtime honored them, and
toggles could not clear them. McpSection now migrates option keys to
the current normalized ids (existing normalized entries win).
- A transient ensureConfigServers failure degraded to an EMPTY server
context, leaving normalized keys unresolvable for the request.
resolveMCPServerContext keeps the name lists (derived from the
config snapshot alone) and degrades only the lazy-init configs.
* 🧯 fix: Complete the Collision Audit at Every Gate; Safer Heal Semantics
Round follow-ups hardening the collision audit:
- Execution guards now consult the FULL accessible set: the caller's
heal threads its already-fetched names through loadTools, and
handleTools fetches them itself when a configured name needs
normalization (never for safe-name deployments) - so a cross-tier
collision (user-DB 'foo' vs operator 'foo!') fails closed at eager
execution instead of joining the run under one normalized name.
- Healing is SKIPPED when the collision audit cannot complete
(transient lookup failure, or no dep): un-healed raw keys still
resolve through the direct-first candidates, so skipping is safe
while rewriting against an incomplete audit is not.
- The audit lookup is gated on the agent actually carrying
delimiter-bearing keys (tools, tool_options, or skill
allowed-tools), so non-MCP agents never pay a registry round-trip
even on specially named deployments.
- normalizeAgentToolKeys gives the CURRENT (normalized) entry
precedence when both spellings carry options, matching the builder's
migration semantics instead of letting insertion order decide.
- The builder's toCurrentToolId resolves entries boundary-exactly
against every configured server (longest match, both spellings), so
a raw suffix shared with a LONGER server name can no longer reassign
that server's selection or options while another dialog is open.
* 🧯 fix: Shared Collision Audit for Definitions Loading; Fail Closed on Audit Failure
Round follow-ups closing the remaining audit gaps:
- The definitions-only loader now consumes the same collision audit as
eager loading: shadowed servers' entries (wildcards included) are
dropped before definitions are emitted, so the default execution
path can never resolve a shadowed server's normalized function name
to another server. The audit names thread from initializeAgent's
heal; the loader self-fetches only when a configured name needs
normalization.
- resolveCollisionAuditNames centralizes the audit-resolution policy
(threaded set > self-fetch when needed > incomplete on failure), and
BOTH loaders now fail closed under an incomplete audit: any
normalization-sensitive reference (its own name needs normalizing,
or it equals the normalized form of a configured special-character
name) is skipped with a warning instead of being audited against
operator names alone. isNormalizationSensitiveName lives in
packages/api as a pure helper so test mocks use the real predicate.
- normalizeAgentToolKeys collapses duplicate ids after healing
(order-preserving): a document carrying both spellings converges on
one key, never two instances with the same function name.
* 🧯 fix: Thread the Audit Everywhere; Identity-Aware Alias Fallback
Round follow-ups on audit plumbing:
- The OpenAI-compatible and Responses tool loaders now forward the
already-resolved accessibleMcpServerNames instead of discarding it,
so the definitions loader neither repeats the registry lookup nor
fails closed on a transient second lookup after the first succeeded.
- The skill-only path threads its audit: when the baseline agent has
no MCP keys but a primed skill's allowed-tools fetched the complete
set, that set (not the operator-only list) reaches the loader, so
the collision remains visible and the shadowed reference stays
rejected end to end.
- OAuth discovery iterates the collision-FILTERED tool list, so a
request can no longer emit an OAuth prompt, wait out the connection
timeout, and reconnect a server whose definitions were deliberately
rejected.
- The definitions loader's alias fallback is identity-aware: when the
parsed name IS a known accessible server, a null tool fetch means
temporarily unavailable (OAuth pending, missing user variables,
disconnected) and no longer reroutes to the raw alias - previously
the aliased operator server's definitions could be emitted under the
unavailable DB server's names.
* 🧯 fix: Legacy-Key Definition Lookup; Retain Audit for Deferred Execution
- createMCPTool resolves tool definitions by BOTH spellings: the key as
persisted plus the canonical normalized key built from the resolved
server name. Assistants and direct tool calls persisted before the
rollout bypass the agent-boundary heal and arrive with raw keys, while
availableTools is now indexed canonically - previously every such call
missed the index, burned a reconnect, and returned the unavailable
stub permanently via the negative cache.
- The initialized agent retains accessibleMcpServerNames (the COMPLETE
collision audit this initialization resolved), buildAgentToolContext
copies it into every per-agent tool context, and loadToolsForExecution
threads it into the eager loader as bare options. Deferred/event-driven
execution therefore reuses the snapshot instead of repeating the merged
registry read - a transient failure there could fail-closed a tool the
same turn already advertised from the successful first audit.
- MCP.spec.js keeps @librechat/api pure helpers REAL (requireActual
spread) so normalization paths are exercised rather than mirrored.
* 🧯 fix: Parse Legacy Keys Against Both Server-Name Spellings
createMCPTool's boundary candidates were normalized-only, so a legacy
raw key whose server name contains the delimiter (foo_mcp_bar!) missed
the suffix match and fell to the generic last-delimiter split - the
canonical rebuild then produced a key that could never hit the index
and the persisted call stubbed out. The candidate list now carries the
RAW resolved name (and raw config names on the parse-only path) next
to the normalized spellings.
* 🧯 fix: Honest Audit Completeness; Shadowed-Server Form-Key Guard
- resolveAllMcpConfigs tolerates ensureConfigServers failures, so the
merged registry read can silently omit config-only servers while the
audit still reported complete: true - a foo/foo! collision would go
unseen and a persisted key could route to the wrong server. Both
audit consumers now union the snapshot-derived raw config names back
in (resolveCollisionAuditNames unions the caller's rawServerNames;
the initializeAgent heal unions configRawServerNames), keeping the
completeness label honest without an extra read: operator names come
from the registry-independent config snapshot, user-DB names from the
merged read that fails loudly into the existing incomplete path.
- The client tool_options migration now mirrors the runtime heal's
fail-closed rule for SHADOWED servers: when the dialog's server has
lost its normalized slot to another catalog name, legacy raw keys
stay raw instead of being rewritten onto the winning server's key,
where a later save would apply the wrong server's per-tool settings.
The dialog's own server joins the alias construction so a stale
catalog map can't misread as a collision.
* 🧯 fix: Heal Legacy Assistant MCP Tool Names on Save
The assistants create/update controllers look tools up in the cached
definitions by exact key, and the cache is now normalized-keyed - an
assistant saved before the convention resubmits its raw-suffixed MCP
name on every edit, so any save silently removed the tool.
healMcpToolNames pre-heals the payload's tool list: a delimiter-bearing
string that misses the cache resolves through the configured raw names
(longest-suffix, boundary-exact) and rewrites to the normalized key
only when that key actually exists in the cache. SHADOWED raw names
stay raw and fail closed, mirroring the runtime heal; the config read
happens only when a delimiter-bearing name actually misses, and read
failures propagate (write path) rather than silently dropping tools.
v2's update loop also stops re-reading the tool cache per iteration.
* 🧯 fix: Full-Audit Shadow Set + Dedupe in the Assistant Key Heal
- The assistant-save heal built its shadow set from operator config
names alone, so a cross-tier collision (user-DB `foo` owning the
normalized slot of operator `foo!`) looked unshadowed and the legacy
key healed into the shared normalized name - which direct-first
execution then binds to the DB server. The shadow set now comes from
resolveCollisionAuditNames' full accessible audit, and an incomplete
audit skips healing outright (every rewrite candidate is
normalization-sensitive by construction, so raw-and-fail-closed is
the only safe answer).
- Healed string entries dedupe order-preserving: a payload carrying
both spellings of the same tool collapses to one entry instead of
expanding into duplicate function definitions the provider rejects.
* feat: surface interrupt-steer escalation on waiting messages
The interrupt & steer feature shipped reachable only through the
composer chord, the send-button hovercard, and the composer button; a
message already waiting (queued for after the run, or steered and
parked at the next tool boundary) had no path to it. Both waiting
surfaces now carry one:
- Queued rows get an icon-only ZapOff escalation button beside the
existing Steer primary. It routes through sendQueuedNow, which now
takes a preempt option on its live-run path. The tooltip teaches the
composer chord, derived through resolveComposerKeyDown so a rebound
or yielded chord is never advertised.
- In-flight steer bubbles get an "Interrupt now" overflow entry with
the same race rules as Edit: reclaim first, and only a `reclaimed`
outcome resubmits (via retrySteer with preempt, swapping the chip
for an interrupting one). `applied` and run-ended-mid-reclaim
outcomes stop at the existing informational toasts, so the words can
never land twice. Not offered on a steer already preempting.
- Every during-run overflow menu gains an "Always interrupt instead"
toggle for steerInterruptsByDefault, next to the existing steer/queue
default toggle. MenuEntry supports disabled for the new entries.
Only one interrupt can be unresolved at a time: while one preempt is
pending (or the run is paused on approval, where the server 409s),
every escalation control disables instead of racing the same seal.
Ten new tests across both surfaces; 381 green in the affected suites.
* fix: lock escalation across its reclaim window, keep the paused control visible, label as steer
Codex round 1, all three findings.
P2, escalation race. The single-interrupt invariant had a window between
clicking "Interrupt now" and the reclaim resolving, where no preempt
chip existed for the chip-derived gate to see: two bubbles escalated
back-to-back could both resubmit. A shared escalating flag (Jotai,
per-conversation) now covers the window and disables every escalation
control on both surfaces, and a fresh recheck before resubmitting
catches an interrupt armed elsewhere meanwhile (composer chord, queued
row); those words re-home to the queue with an informational toast
instead of breaking the invariant.
P2, unreachable paused state. canSteer is defined as
hasRealConvoId && !pausedOnApproval, so gating the button on canSteer
removed it exactly when it was meant to render disabled; the test only
passed on an impossible stub combination. The render gate is now
duringRunActive && (canSteer || pausedOnApproval), and the test uses the
real invariant.
P2, label semantics. "Interrupt & send now" borrowed the name of the
hard-abort action; this one preserves the partial answer and steers.
Renamed to "Interrupt & steer now" (com_ui_interrupt_steer_now).
Both behavior fixes counterfactually verified; 384 tests green across
the affected suites.
* fix: disable bubble escalation while the run cannot accept a steer
Codex round 2, one P2. Answer mode (ask_user_question) sets
duringRunActive false while pausedOnApproval stays false, since that
flag only detects approval-bearing tool calls. The bubble's escalation
entry stayed enabled there, so clicking it cancelled a healthy waiting
steer and the preempt resubmission bounced off RUN_PAUSED, degrading
the words to the queue. The entry now also disables on
!duringRunActive, matching the queued-row control's gate.
Counterfactually verified: reverting the gate fails the new
answer-mode test.
* fix: recheck live run state after the reclaim, not just at the click
Codex round 3, one P2, and it is the round-1 recheck principle applied
one level deeper: the entry-time disable cannot see a run that pauses
(tool approval, answer mode) while the reclaim round-trip is in flight,
and the .then closure held the render's stale steering controls, so the
resubmit would fire into a RUN_PAUSED rejection after the reclaim had
already surrendered the steer's boundary slot.
The escalation continuation now reads the LIVE controls through a
latest-ref: if the run can no longer accept a steer, the words re-home
to the queue with an informational toast instead of resubmitting, and
the resubmit itself also goes through the live controls.
Counterfactually verified: reading the stale closure instead of the ref
fails the new mid-reclaim pause test.
* refactor: make escalation one atomic server-side arm, in place
Codex round 4: four P2s, every one an interleaving of the same window —
escalation as reclaim-then-repost is a compound, non-atomic operation
whose continuation must revalidate the world (FIFO position lost, ref
assigned too late, no run fence, competing bubble actions). Rounds 1-3
patched that window with a lock and rechecks; round 4 shows the window
itself is the defect, so this removes it instead of guarding it again.
Escalation is now POST /chat/steer/arm: the server flips preempt on the
EXISTING queued item in one atomic store op (new IJobStore.armSteer; a
decode-patch-encode LSET Lua on Redis, an in-place mutation in memory),
fenced to the validated generation and refused once the queue closes.
The handler mirrors the steer POST's preempt contract exactly: durable
flag gated on the owner's recorded capability, volatile requestPreempt
fire-and-forget because the durable flag is the truth resume/handover
re-arm from.
By construction this resolves all four findings: FIFO survives (the
item never moves; the whole queue still drains in instruction order at
the seal), there is no continuation to hold stale controls, the store
op is fenced to the original run, and a competing Edit/Queue/Cancel
either beats the arm (armed:false, chip untouched) or operates on the
armed item, whose cancel already disarms.
The client escalation entry becomes one mutation: armed:true relabels
the chip in place (same steerId, same position), PREEMPT_UNSUPPORTED
and lost races toast honestly, and the round 1-3 machinery — the
escalating lock atom, the latest-ref, the post-reclaim rechecks and
their two toast strings — is deleted rather than extended.
Verified: 7 new handler tests on the real in-memory manager (including
FIFO preservation and the stale-generation fence), 2 Redis integration
tests against real Redis (in-place arm keeps order and every field;
missing/stale/closed all refuse), client suites 396 green.
* fix: decide capability inside the atomic arm, neutralize the lost-race toast
Codex round 5, both findings, both edges of the new arm design rather
than its mechanism.
P2, capability TOCTOU. A HITL resume on a rolling deploy rewrites
preemptCapable for the SAME generation, so the handler's read could go
stale between validation and the flag flip, arming a steer the live
owner cannot seal. armSteer now returns armed | missing | incapable,
with the owner's live capability part of the same atomic predicate as
the generation fence (HGET preemptCapable inside the Lua; the flat job
field, not a metadata blob — the in-memory store reads the same field).
The handler's pre-check is deleted rather than kept alongside; the
store predicate is the single source. New handler test rewrites the
capability after queueing and expects PREEMPT_UNSUPPORTED with the item
left unflagged; the Redis guards test now asserts the incapable refusal
against real Redis.
P2, ambiguous toast. armed:false covers injected, cancelled, re-homed,
and run-over alike, so telling the user the message "already reached
the agent" claimed one specific outcome. The lost-race branch now uses
a neutral message (com_ui_steer_arm_lost_race) and defers to the events
for what actually happened.
* fix: flip the escalation lock synchronously before the arm request
Codex round 6, one P2. Round 4 deleted the escalating flag along with
the reclaim continuation it guarded, but that left the one-interrupt
gate blind during the arm request's own round trip: the chip-derived
check cannot see an arm until its response relabels the chip, so on a
slow connection two bubbles could both arm before either response
landed. Double-arm is harmless server-side now (the run seals once and
drains the whole queue in order), but every escalation control
advertises "one interrupt at a time" by disabling, and the controls
must tell the truth.
The per-conversation escalating flag returns as a pure UX gate: set
synchronously at click, before the mutation, cleared on settlement, and
folded into interruptPending on both surfaces. Unlike its round 1-3
ancestor there is no continuation behind it to guard and no recheck to
pair with it.
Counterfactually verified: without the synchronous set, the two-bubble
race test arms twice. 207 tests green across the Chat Input suites.
* test(e2e): cover escalation of waiting messages through the real seal
Three mock-harness tests on E2E_SLOW_REPLY, a 160-chunk stream with no
tool boundary, so an in-thread steer part can ONLY come from a genuine
mid-stream seal — which makes each test a behavioral proof rather than
a UI check:
- Queued row escalation: the ZapOff button turns a waiting queued
message into a preempt-armed steer (202 echoes preempt: true) that
seals and injects, where the sibling steering.spec test proves the
unescalated path waits for run end instead.
- Bubble in-place arm: an ordinary steer (202 with no preempt echo)
waits as a bubble, POST /chat/steer/arm answers armed: true, the
bubble relabels in place (same single bubble, same text, escalation
no longer offered on reopen), and the armed steer seals mid-stream.
- Always-interrupt toggle: flipped from a waiting row's overflow menu,
plain Enter now produces a preempt: true steer that seals in the SAME
run, and the menu offers the way back. An afterEach clears the
localStorage preference so a mid-test failure cannot leak
preempt-by-default into the rest of the serial suite.
All three verified locally through the full harness (real backend, mock
LLM, seeded DB): 3 passed in 27s.
* feat: dedicated escalation arrow + shortcut, menu split into actions and preferences
The escalation was still half-hidden: the bubble only offered it inside
the overflow menu, and the tooltip taught the composer chord, which does
a different thing (interrupts with typed text, not this chip). Three
changes make it a first-class command:
- A shared EscalateNowButton (circular arrow, ghost-bordered like the
composer's interrupt control) is always visible on BOTH surfaces:
beside each queued row's Steer primary and on every waiting steer
bubble next to its menu. It disappears once a steer is interrupting.
- A dedicated registry shortcut, escalateSteer (Cmd/Ctrl+Shift+.),
editing-allowed and rebindable like every other action. Deliberately
NOT an Enter chord: the composer owns every Enter chord, and the
yield design rests on no default binding using Enter besides submit.
Its handler clicks the newest enabled arrow control (bubbles beat
queued rows), so the shortcut can never diverge from the button, and
the arrow's tooltip teaches THIS command via the registry display.
- The overflow menus separate one-off actions from sticky behavior
changes: Edit, Cancel, Queue, then a smaller "Preferences" section
holding the queueing and always-interrupt toggles, each with the
standard InfoHoverCard reusing the Settings panel's descriptions.
"Interrupt & steer now" leaves the menu entirely.
386 client tests green, including a menu-structure test locking the
order and the absence of the escalation entry; bubble escalation tests
drive the visible arrow. The e2e spec's bubble test now clicks the
arrow, and a fourth test drives the dedicated shortcut end to end
through a real mid-stream seal.
* style: bind the escalation arrow to its message (variant A anatomy)
Two same-weight circles in a row read as one control group, leaving the
arrow's ownership ambiguous, and a floating arrow stops meaning anything
once several messages stack. The shared control now carries variant A's
anatomy: a thin divider binds a small SOLID arrow (filled, inverted) to
the message region on its left, and the menu ellipsis stays a bare
glyph, so the two affordances can no longer blur together — and the
divider+arrow pairing repeats cleanly per chip at N messages.
* chore: drop the unused within import CI lint caught
* fix: advertise the escalation shortcut only while the control is live
Codex on the e2e head, one P2: the tooltip appended the chord hint even
while the button was disabled, advertising a shortcut that does nothing
during an approval pause. The flagged control (InterruptNowButton) was
since replaced by the shared EscalateNowButton, which inherited the
pattern; the successor now omits the chord whenever the control is
disabled, matching the rule the during-run hovercard already follows.
* fix: harden steer escalation lifecycle and recovery
* test(e2e): disambiguate accessible steer preferences
* test: align abort persistence coverage with prerequisites
* chore(i18n): remove obsolete steer race message
* chore: normalize imports across steering changes
* test: exercise stream integration on Redis Cluster
* test: scope HITL checkpoints to generation
* test: fix cluster cleanup and locale policy
* fix: keep escalation visible during ask pauses
* fix: fence recovery downgrade and stale predecessors
* fix: require generation owner abort acknowledgement
* fix: validate delayed preempt arms
* test: align final escalation fixtures
* fix: preserve in-memory predecessor abort handoff
* fix: restore controls for recovered queued messages
* test: cover recovered queue controls
* fix: close final steering review gaps
* 🎯 feat: Per-Tool Intent Label Toggles in the Agent Builder
Saved agents have had per-tool intent control on the backend since the
capability landed (tool_options[name].describe_intent, consumed by
applyIntentLabels), but the builder offered no way to set it - the
capability was invisible to saved agents on MCP tools, which default
off. This is the deferred UI slice.
The MCP tools panel gains a fourth per-tool option toggle (Captions
icon, teal) next to defer / programmatic / background, plus the
matching section-header bulk toggle, gated on the tool_intents
capability. The toggle writes describe_intent: true through the same
withBooleanOption path the sibling flags use, so an opt-in composes
with existing entries and clearing the last flag drops the tool's
entry entirely.
No backend changes: the agent CRUD schema already validates
describe_intent and initialization already consumes it.
* 🧯 fix: Keep the Intent Toggle Truthful for Programmatic-Only Tools
A tool marked Programmatic in the builder gets allowed_callers:
['code_execution'], and the backend's canInjectIntentParam deliberately
skips non-direct tools (no card renders for calls made from code), so
an intent opt-in on such a tool is guaranteed inert. The UI could
nevertheless show both settings active.
The intent toggle now mirrors the runtime gate: isToolProgrammaticOnly
(allowed_callers set and missing 'direct', the exact backend predicate)
renders the per-row toggle inert with a tooltip explaining why, shows
it unpressed regardless of any stored flag, and the bulk toggle and its
all-state consider only tools the label can actually reach. The stored
describe_intent value is preserved, so unmarking Programmatic restores
the user's earlier choice instead of destroying it.
OptionToggle gains a disabled state (dimmed, non-interactive, tooltip
kept) shared by the row and bulk variants.
The ask_user_question pause/answer stamps (server pause-time args stamp,
resume-time answer stamp, and the client mirror) targeted the newest
unanswered ask part by position. When a model emits several ask calls in
one turn, the interrupt's question and the user's answer land on the
wrong card.
@librechat/agents > 3.3.8 surfaces the interrupting call's tool_call_id
on the ask interrupt payload. All three stamps now target that id
exactly when present, keeping the positional fallback for older
payloads. The tool body passes config.toolCall.id through to
askUserQuestion via a typed alias that is a no-op on the pinned SDK and
lights up on the next dependency bump.
Companion to danny-avila/agents#366, which also fixes the underlying
dangling tool_use 400 (INVALID_TOOL_RESULTS) when one of the parallel
ask calls streams malformed args.