Skip to content

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

Merged
pull[bot] merged 11 commits into
innFactory:mainfrom
danny-avila:main
Sep 14, 2026
Merged

pull[bot] merged 11 commits into
innFactory:mainfrom
danny-avila:main

Conversation

@pull

@pull pull Bot commented Sep 14, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


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

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

danny-avila and others added 11 commits September 14, 2026 00:58
* 🧭 fix: Let a Sealed Chat Follow Its Agent to a New Machine

A conversation's code-environment decision is sealed on its first turn, but the
environment an agent uses stays editable. Pointing an agent at a different
attached machine left every chat sealed to the old one with no valid move: the
client showed an unusable workspace chooser, and any other selection was
rejected by the server as locked. Forking copied the stale seal.

The owner can now move such a chat explicitly. The composer names the machine
the chat used and the one its agent uses now, and moving it goes through an
owner-scoped endpoint that compare-and-swaps the persisted decision after
validating the new workspace against the live worker. A move never switches the
workspace of an environment the decision already covers, never upgrades a chat
that continues without an attached environment, and is refused while a
generation is in flight.

A saved chat also no longer auto-selects a sole workspace for an environment its
decision never covered, which submitted a selection the server always rejected.

* fix: Hold Moves Through Terminal Saves and Move Every Target at Once

A terminal claim marks a generation complete before its response save lands,
and that save still writes the decision the run started with, so a move is now
refused until terminalPersistencePending clears.

The API advertises codeEnvironmentMoveVersion, and the client offers a move only
when it is present, so a new client never exposes an endpoint an older API lacks.

The move menu picks one workspace per new environment, with a sole workspace
preselected, and a single confirm sends the complete target set in one request,
so a partial move can no longer leave a half-migrated decision. A new machine
that advertises no workspace shows its unavailable state instead of an empty
section, and the confirm stays disabled.

* fix: Keep Runs From Rewriting Moved Decisions and Gate Moves by Policy

Every agent turn used to persist the decision it started with, so a run from any
ingress that settled after a move could write the old decision back: the chat
controller reads the conversation before its generation job exists, and
Responses API runs never enroll one. Runs now persist a decision only for a
conversation that does not store one yet, and a legacy row records only the mode
it inferred, so no admission or persistence order can revert a move.

Moves are now an operator setting, statefulCodeSessions.conversationMoves.enabled,
off by default. The API advertises codeEnvironmentMoveVersion and accepts moves
only where the effective policy enables them.

A saved chat carrying attached selections no longer auto-selects a sole workspace
even when selection-less decisions are not advertised, because the server seals
those selections either way.

* fix: Fence Moves Across Remote Runs and Let Moves Drop Unused Machines

A Responses API run is keyed by its response id, so the conversation stream
alone could not show it still working on a chat. A move is now refused while any
generation that can still act for the conversation exists, using the same
conversation-scoped enumeration conversation deletion drains.

A move now validates every workspace it persists, not only the added ones, so a
successful move never hands back a decision whose kept workspace is gone.

A chat sealed to machines its agents no longer all use could not be sent or
moved: the client trimmed the unused selection and the server rejected the
trimmed set as locked. A saved chat now treats that seal as unresolvable, offers
a move that drops the unused machine, and the server accepts drop-only moves.

* fix: Own the Decision Fallback in TypeScript and Settle Move Caches

The fields a run may persist when it never resolved a decision were chosen by a
branch in AgentClient; resolvePersistableCodeEnvironmentDecision now owns that
fallback too, under the same never-overwrite rule, and the CJS method only wires
the request into it.

Workspace choices made in the move menu belong to the chat they were made in, so
another relocatable chat targeting the same machine starts undecided.

A successful move cancels conversation, pinned, and list reads already in flight
before writing the moved decision, so a response from before the move cannot
restore the replaced decision in the cache.
* fix: elect one expired-file sweeper

* fix: unify clustered sweep ownership

* fix: retain clustered sweep fallback

* fix: use normalized Redis sweep ownership

* fix: reconcile sweep startup and concurrent leader ownership
* 📜 style: Scroll the Sidebar as One Surface

Projects, Pinned and the chats list each owned a scroll pane, so only the
chats list scrolled while the sections above it stayed pinned. The sidebar
now has one scroll viewport holding all three: the virtualized chats list
renders at its natural height and is windowed by that viewport instead of
scrolling itself.

`useOuterScrollWindow` translates the viewport's scroll position into the
list's coordinates. It re-measures on scroll and observes both the viewport
and the content wrapper, because a section collapsing above the list moves
the list without resizing either the viewport or the list.

* 🧪 test: Cover the Sidebar's Single Scroll Surface

Four scenarios in the mock harness: the whole sidebar rides one wheel
gesture, older chats keep loading as it reaches its end, collapsing a
section above the chats leaves the visible rows painted, and a long
pinned list scrolls with the sidebar instead of inside its own box.

The chat list grid no longer matches its container's height — it renders
at the height of the rows it holds — so the width-tracking spec measures
width alone and seeds the rows it measures.

* 🪟 fix: Keep the Windowed List Honest Without ResizeObserver

A section collapsing above the chats list moves it without resizing the
viewport or the list and without a scroll event. Where ResizeObserver is
missing, the fallback only listened for window resizes, so the list kept
windowing against the old offset and could paint the wrong rows. It now
watches the content wrapper the same way the observer does: its mutations
and the bubbling transitionend of its height tween.

The hook gets its own spec — offset translation, clamping at the top,
frame coalescing, the collapse re-measure, the fallback trigger and
listener cleanup — and the scenario specs reach the drawer on emulated
touch devices, where the header's opener appears only once the chat has
rendered and a wheel gesture is not what a person produces.

* 👁️ fix: Window the Chats List on What the Reader Can See

The list was handed the whole sidebar's height as its window, so with a
screenful of projects or pins above it, react-virtualized counted a
viewport of chat rows as rendered at the top of the list and the
eight-row pagination threshold was crossed before a single chat had been
exposed — a second conversations request nobody had asked for, on the
load path the Lighthouse lane guards. The window is now the slice of the
list the viewport actually shows: zero while it is below the fold, the
viewport's height once it fills it.

Searching also kept the shared surface's scroll position while replacing
what it held — Projects and Pinned leave and the chats become results —
so results long enough to escape the browser's clamp opened partway
down. The surface returns to the top whenever it swaps contents.

* 🧪 test: Seed the Pagination Scenario's Pins Out of the First Page

* 🧪 test: Claim the Settled Layout in the Pagination Scenario

* 🩹 fix: Keep a Pixel of Window So an Unseen List Can Be Reached

A list handed a zero window renders nothing, and react-virtualized only emits its inner container when it has children, so the list collapsed to no height and the surface had nothing to scroll toward: the chats below a screenful of pins could never be reached. The window keeps a single pixel, which renders the first row and restores the list's own height without claiming a screenful is on display.

* 🧪 test: Scroll Until the Chats List Answers

* 🚦 fix: Ask for Another Page Only Once the Chats Are in View

A list below the fold still renders its first row — that pixel of window
is what gives it a height to be scrolled to — and on a page whose chats
are nearly all pinned, that single row already sits inside the eight-row
threshold, so the list asked for another page before anyone had looked at
it. Reaching the end of what is rendered now counts only while the list
is on screen; the hook reports that separately from its height, and a
page holding no chats at all is still drained by the all-pin effect.

* 🔎 fix: Ask the Layout Whether the Chats Are in View

Visibility was a value sampled on a frame, and a commit that swaps what
the sidebar holds — leaving a search restores the sections and the
unfiltered page together — moves the list before any observer runs, so
rows reported during that commit were judged against the layout before
it. The hook now answers from the layout as it stands when asked, and
the pagination gate asks.
…15913)

* 🧷 fix: Skip Failed Summaries When Resolving the Conversation Checkpoint

* 🧷 fix: Keep a Failed Summary Out of the Model-Facing History Boundary

* 🧭 refactor: Own the Summary Checkpoint Predicate in packages/api

* 🧯 fix: Reject Unfinished Summaries Everywhere and Recount Stripped Turns

* 🧬 fix: Version Event-Actor Summary State So Legacy Prefixes Cannot Warm

* 🏷️ fix: Stamp Inherited Summaries Where Actor State Is Assembled

* ⚖️ fix: Drop Unusable Summaries While Building the Prompt

* 🧷 fix: Repoint Prompt Content Instead of Splicing Stored Parts

* 🧭 fix: Treat a Streamed Summary Without a Boundary as Unfinished

The server has stamped `failed` on an errored summarize round only since
#14546, and `summarizing` never reaches storage, so every round that errored
on v0.8.7 or rc2 was persisted as a plain summary holding its partial deltas.
The flag checks cannot tell that row from a checkpoint, so upgraded
conversations kept losing the history before it.

The aggregator settles it structurally, identically in every SDK release
that has shipped compaction (3.1.63 through 3.8.6): deltas stream `content`
blocks into the part, and only a completed round replaces the part with the
final block, which is the sole writer of `boundary`. A `content`-block summary
without a boundary therefore never finished. `isUsableSummaryPart` and the
shared `isCompactedLeaf` both apply that rule; bare-`text` rows predate the
aggregator and stay governed by the flags.

Fixtures that stand for completed summaries now carry the boundary a real
run writes, and the flag fixtures keep one so they still test the flags.

* 🔁 fix: Strip Unusable Summaries From Responses API Continuations

`previous_response_id` loads the stored conversation and hands each message's
`content` array straight to `formatAgentMessages`, whose summary scan takes
the last summary part carrying text as the history boundary. The chat client
strips unusable summaries before that scan; this entry point did not, so a
conversation continued through the Responses API still lost everything before
a failed round. The same packages/api helper now runs ahead of the formatter.

* 🧭 refactor: Own Event-Actor Summary Selection in packages/api

`getLatestEventActorSummary` decides which summary a warm continuation
carries forward, how a missing token count is recorded, and how the state is
stamped. That is compaction behavior, and every helper it relies on already
lives in `packages/api/src/agents`, so it moves beside `isUsableSummaryPart`
and the controller imports it.

* 🧪 test: Give the Finished Compaction Leaf the Boundary a Completed Round Writes

`isCompactedLeaf` now requires the boundary only a completed summary block carries, so the client fixture for a finished compaction needs one, and a boundaryless leaf joins the interrupted compactions that can be retried.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
* fix(schedules): forward renewable MCP credentials

* fix(schedules): require unattended MCP credentials

* fix(schedules): resolve MCP credentials during execution

* refactor(schedules): inject unattended credentials

* fix(schedules): scope renewable credential resolution

* fix: isolate lazy scheduled OBO credentials across MCP lifecycle

* fix: preserve MCP cancellation and retryable credential lookup

* fix: bind MCP cancellation to the owning run signal

* test: supply flow manager in OBO cancellation regression

* fix: preserve MCP cancellation across exchange and API ingress

* style: sort MCP manager imports

* fix: propagate cancellation through agent discovery

* test: preserve agent discovery edge literals

* fix: propagate agent initialization cancellation

* fix: expose initialization signal in OpenAI adapter
* 🧯 refactor: State Failed Turns in Copy a Reader Can Act On

Every client-facing error code now has authored copy, so no failed turn renders
raw JSON, an unresolved `com_error_*` key, or a provider message cut at 512
characters. `client/src/components/Messages/Content/Error.tsx` becomes a module
directory: a dispatcher resolving `json.code ?? json.type`, a registry splitting
one-sentence copy from payload-aware renderers, and shared parts that resolve the
provider, model, user-key ownership and compaction capability behind the failure.

Message-limit violations persist `resetAt`/`retryAfterSeconds`, so the row counts
down to the window's reset instead of naming a window length alone.

* 📸 chore: pin before/after error copy screenshots for the pull request

* 🧹 chore: drop the pinned pull request screenshots from the tree

* 🔻 style: Trail the error disclosure chevron after its label

* ⏱️ refactor: Derive Limiter Resets in packages/api, Stamp Key Expiry as ISO

The message limiter's reset fallback and retry arithmetic move out of the
CJS middleware into `getRateLimitReset` in packages/api, which both the
message and agent-event handlers now call, so /api keeps only the wiring.

`checkUserKeyExpiry` persists `expiredAt` with `toISOString()` instead of
the server's `toLocaleString()`, which a client in another locale could
parse with day and month swapped.

* 🪪 fix: Attribute Failed Turns to the Row, Agent and Payload That Failed

Error content parts render through `Part` without their message, so their
copy named the conversation's current endpoint and model rather than the
ones that failed. The chat, flat-thread and search row renderers now supply
the row's identity through `ErrorSourceProvider`, and the dispatcher reads
it whenever no row is passed directly.

A saved agent's row carries the `agents` endpoint and the agent id as its
model, so identity, key ownership and the key dialog resolve against the
agent's own provider and model; an endpoint named by the payload (an
expired key) outranks both. The agent builder is only suggested where the
side panel would offer it: agents configured, builder not disabled, USE and
CREATE held, and an EDIT grant or administrator role.

Also in the renderers:
- structured refusals read Anthropic's `stop_details.explanation`
- provider bodies nesting their message under `error` keep that message
- upstream model failures name the provider when it is known
- `INVALID_USER_KEY` offers the key dialog on a user-provided endpoint
- an oversized message is never told to compact the conversation
- the countdown is bounded by the limiter's window, not a fixed day, and its
  ticking text is hidden from the assertive live region
- an expiry persisted in a server locale is shown as written
- search rows that failed render through the dispatcher, not as markdown
- the payload type is an explicit JSON value type

* 🧪 chore: Seed Real Refusal, Legacy Expiry and Nested Provider Error Shapes

The gallery's refusal case used a string `info` no producer writes; it now
carries Anthropic's stop metadata and a Bedrock content filter. It also
seeds an expiry stamped in a server locale, an unreadable key on a
user-provided endpoint, and a provider body nesting its message under
`error`, and removes whatever it inserted when a write fails.

* 🧵 fix: Keep the Reason From a Provider Body Embedded in Failed-Run Text

A failed run persists a provider error as `<base message>: <SDK message>`,
and Anthropic's SDK message embeds the response body, so the persisted text
reads `…request: 400 {"type":"error","error":{"message":…}}`. The
fallback renderer dropped that body and kept only the prefix, losing the
reason. It now swaps the body for the message it carries.

* 🔀 fix: Resolve Handoff Agents, URL-Owned Credentials and Nested Error Codes

An error part after an `AGENT_UPDATE` belongs to the agent the run was
handed to. `ErrorSourceProvider` now records where a row's handoffs sit,
and an error part resolves the agent active at its own `partIndex`; search
and shared rows give error parts that position too. A handoff the agents
map cannot resolve names no provider instead of the agent that handed off.

A user-provided base URL makes the reader's own record hold the key as
well, so key failures on such an endpoint offer the key dialog. `NO_BASE_URL`
and `INVALID_BASE_URL`, which only a user-provided URL produces, render
through the same renderer with named copy and an add or update action.

OpenAI-compatible bodies nest `code` and `type` under `error`; when the top
level names neither, the dispatcher reads the envelope, so a nested
`invalid_api_key` reaches the key renderer.

* 🕰️ fix: Keep Unknown Key Ownership Generic, Measure Resets From the Row

A shared link's viewer has no endpoint configuration, so whether the reader
or the deployment owns a key is unknown; key copy there read that as
deployment-owned and told the viewer to ask an administrator. Ownership is
now undefined without a configuration, and the copy stays generic.

The countdown's window bound used this device's clock, so a device running
behind the server could drop a valid reset. The window now runs from the
row's own timestamp, which the server stamped alongside `resetAt`.

* 🗜️ fix: Suggest Compaction Only Where the Compact Action Is Offered

Manual compaction is only reachable from the context usage popover, which
`interface.contextUsage: false` removes. Context errors on such a deployment
suggested compacting anyway; they now fall back to the standard next steps.

* 🧭 fix: Keep Row Identity Authoritative and Every Generation Charge Visible

A row's own endpoint and model now stand even where it lacks them, so a
restored failure no longer borrows whatever the conversation selected since;
only an error rendered with no row reads the conversation, and an agents row
with no stored agent id (the live row of a turn that just failed) still
resolves the conversation's agent.

Also:
- a token-balance row lists every generation instead of the first 20
- search rows re-render when their `error` flag changes
- `AUTH_CROSS_ORIGIN`, added since this branch began, renders the login
  page's copy and has a gallery case, so the seed's completeness check runs;
  a table test now fails for any enum member without authored copy
- upstream model failures keep the provider-neutral sentence the server
  persists as their text, which the e2e suite reads the row by

* 🪞 fix: Resolve Ephemeral Agent Ids and Keep an Agents Row's Model Name

An agents row whose model is a model name, as an ephemeral agent's is,
lost both its provider and its model whenever no saved agent resolved.
Agent ids now resolve the way sibling headers resolve them: a saved agent
through the map without the parallel `____N` suffix, an ephemeral agent
through the endpoint and model its id encodes. A row that still resolves
nothing keeps the model name it recorded; an agent id is never shown.

* 🛤️ fix: Attribute Lane Errors to Their Agent, Count Live Retries Relatively

A parallel run stamps each part with the agent of its lane, and an error
part in one lane was attributed to whichever handoff last preceded it. The
row now records error parts' own agents alongside its handoff positions,
and an error part's own agent comes first.

A live countdown subtracted this device's clock from the server's
`resetAt`, so a device running fast could report the limit already over.
`retryAfterSeconds` is now counted from the row's own timestamp, which the
device stamps on a live row and the server on a restored one.

Search results read a cached startup config but have no compact control, so
compaction guidance now also requires the chat surface.

* 🧷 fix: Resolve an Agents Row From Its Own Model, Never the Current Agent

An agents row that stored a model name, as an ephemeral agent's does, fell
back to the conversation's current agent when no saved agent resolved, so
switching the conversation to a saved agent later rewrote who produced the
old failure. The fallback served no real row: the client's live placeholder
for an agents turn stores the conversation's agent id as `model`, as
persisted rows do. The row's `model` now decides on its own — a saved or
encoded ephemeral id resolves, a model name is kept, and the `ephemeral`
placeholder names nothing; only an error with no row reads the conversation.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
* fix: route Azure Astra through Responses API

* fix: keep Azure Astra deployment alias out of summarizer overrides

The Azure deployment alias in `modelKwargs.model` is spread after `model`
by langchain, so the SDK's same-provider summarizer, which copies the
agent's client options and overrides only `model`, still reached the Astra
deployment when configured with a different model. Hand the summarizer the
agent's kwargs without the alias whenever its model differs.

Also derive the Astra routing decision once, reuse the first-party OpenAI
predicate in the GPT-5.6 rule, pin the opt-out shape in the Azure spec, and
expect `firstPartyEndpoint` for canonical Azure hosts in the backward-compat
spec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: resolve Azure request URLs and summarizer deployments

* fix: honor explicit OpenAI summarization for Azure agents

* 🧭 fix: Degrade Unresolvable Azure Summarizers Without Failing the Run

Summarization target resolution for Azure agents threw inside createRun, so a
summarization setting that could not be resolved failed every agent run before
any model call, not just compaction. The documented `summarization.provider:
openAI` block with the `.env.example` default `OPENAI_API_KEY=user_provided`
broke all Azure agents, and a summary model missing from the Azure
configuration did the same.

- An explicit OpenAI summarizer without server credentials now disables
  summarization for that Azure agent with a one-time warning instead of
  throwing; the agent's own client is not a fallback because Azure Responses
  shares the `openAI` provider.
- A summary model the Azure configuration cannot resolve warns once and keeps
  the agent's client, as before this branch.
- A summary group without a base URL no longer inherits the agent group's
  `azureOpenAIBasePath`, which sent the summary key and deployment to the
  agent's resource.
- Serverless summary groups without a version send no empty `api-version`.

* 🧪 test: Assert No Summary Request Leaves a Disabled OpenAI Summarizer

* 🧯 fix: Disable Summaries Whose Azure Deployment Cannot Be Resolved

When the Azure configuration could not resolve a summary model, the summarizer
fell back to the agent's own client. On an Azure Responses agent that stripped
the deployment alias and sent the logical model name where Azure expects a
deployment, so every compaction failed; on a chat agent the URL-pinned
deployment silently served a different model than the one configured.

An unresolvable Azure summary target now disables summarization for that agent
with a one-time warning, matching the unreachable OpenAI target: the run
proceeds on context pruning and no summary request is sent.

* 🔑 fix: Treat an Empty Azure Summary Key as Unavailable

`azureBaseSchema` accepts `apiKey: ''`, and `mapModelToAzureConfig` passes it
through, so a summary group with an empty key kept summarization enabled and
built a client with no credential. The agent flow already rejects an empty
Azure key during initialization; the summary resolver now disables
summarization for that target with the same one-time warning as a
user-provided key or base URL.

* 🧭 fix: Resolve Azure Summaries From Every Supported Azure Configuration

Three Azure configuration shapes the summary resolver and URL builders did not
cover:

- Legacy `AZURE_*` environment credentials: without an `azureOpenAI` block the
  resolver skipped Azure, so an Astra agent's summarizer reused the agent's
  Responses client and sent the logical summary model where a deployment
  belongs. The target now resolves from the environment the way
  `initializeOpenAI` does (including `AZURE_USE_MODEL_AS_DEPLOYMENT_NAME`) and
  is disabled when those credentials are user-provided or missing.
- `summarization.parameters` `baseURL` and `apiKey` overrides now apply to Azure
  summary targets, as they already did for explicit OpenAI targets, through one
  shared helper.
- A full Azure hostname used as the instance name (as `genAzureEndpoint`
  accepts) is used as the host for both the default Responses URL and the chat
  deployments path, instead of being suffixed into
  `<host>.openai.azure.com`.

* 🔐 fix: Expand Summarizer Credential Placeholders and Bound Warnings

Admin-authored `summarization.parameters` `apiKey` and `baseURL` values were
forwarded verbatim, so `${SUMMARY_KEY}` reached the client as a literal key
while Azure groups and custom endpoints expand the same syntax. They are now
expanded once through `extractEnvVariable` (keeping its sensitive-variable
denylist) before resolution and before the parameters are layered over the
client, and a credential or base URL that still holds an unresolved
placeholder disables summarization like a missing one. Azure base URL
templates (`${INSTANCE_NAME}`, `${DEPLOYMENT_NAME}`) are not treated as
unresolved.

The one-time warning set is now keyed per tenant, so one tenant's report no
longer hides another's, and resets after 256 distinct entries so changing
configurations cannot grow it without bound.

* 🧩 fix: Keep Azure URL Templates Out of Summarizer Env Expansion

Expanding `summarization.parameters.baseURL` through `extractEnvVariable`
also replaced Azure's reserved `${INSTANCE_NAME}` and `${DEPLOYMENT_NAME}`
templates whenever the host environment happened to define those names, so a
template override could route compaction to the host's instance or deployment
instead of the summary group's. Expansion now skips those two templates and
leaves them for `constructAzureURL`, and the unresolved-placeholder check reads
the same segments.

The warning deduplication set now evicts the least recently seen entry at its
bound instead of clearing, so warnings for problems that are still recurring
are not all logged again at once.

* 🔀 fix: Reset Inherited API Mode for Custom-Endpoint Summarizers

A custom OpenAI-compatible summarization endpoint resolves to the same `openAI`
provider as an Azure Responses agent, so the SDK layered its overrides over the
agent's client options and kept the agent's `useResponsesApi`,
`firstPartyEndpoint`, reasoning and request kwargs. An Azure Astra agent
therefore sent its compaction to the gateway's `/responses` with Astra shaping.
When the resolved custom provider matches the agent's, those fields are now
replaced from the target's own configuration, and explicit summarization
parameters can still opt back in.

Azure's reserved `${INSTANCE_NAME}`/`${DEPLOYMENT_NAME}` URL templates are now
preserved only for Azure summarization targets, so an OpenAI or custom base URL
can reference environment variables with those names.

* fix: Resolve Azure Summary Identity, Transport, and Token Limits 🧭

* fix: Resolve Headers Before Building Summary Clients 📨

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
* 🪧 feat: Unify Artifact Triggers as Tool Rows

Every chat-side artifact trigger — code-execution output, a model-authored
`:::artifact` block, and a mermaid diagram — now renders through one
`ArtifactRow` that sits on the tool-row axis instead of three card variants
in a wrapped chip strip. The row carries the file type twice: the language's
brand glyph where one exists, and a format badge for titles that are not
filenames. `kind.rendersPreview` tints the glyph and folds the same fact into
the accessible name, so the row says whether opening it yields a rendered view
or source to read.

The mermaid panel routes every download through its export menu — SVG, PNG and
the diagram source — rather than parking a second, unlabelled button beside it.
The menu turns the pressed format into its own loading row, defers the
synchronous SVG export by a macrotask so the spinner paints before the frame
blocks, and no longer disappears on the code tab. A mermaid download keeps its
real filename: its content *is* the stored `.mmd`, so it is not suffixed
`.preview`.

`useArtifactDownload` holds the original-file vs. edited-content decision that
both the generic download button and the mermaid menu need.

* 🩹 fix: Save the Stored Artifact File, Not Its Cached Text

The panel serialized a mermaid diagram from `artifact.content`, which is the
backend cache `extractUtf8` truncates at 512 KB, so "Download source" saved a
corrupt `.mmd` for any larger stored diagram. An unedited mermaid artifact now
takes the same original-file route as every other text-backed artifact,
`handleDownload` reports whether bytes were actually delivered, and the export
menu turns a failed delivery into its failure status and toast instead of
"Diagram download started.".

A `text/plain` row announced "Opens as source" while the panel opened it on the
rendered markdown preview; it now announces the preview. A pending preview
placeholder keeps the row slot it arrived in rather than sinking to the end of
the artifact group and jumping up once it resolves.

* 🧪 test: Exercise Artifact Rows and Mermaid Exports End to End

Seven acceptance scenarios in the mock harness cover what the unit suites
cannot: that a message which writes five artifacts shows five trigger rows on
one axis, that a row's accessible name matches what the panel actually does
with the artifact (rendered preview vs. source, including `text/plain`), that
the mermaid export menu offers SVG, PNG and the diagram source on both the
preview and the code tab, that "Download source" delivers the stored file and
announces a failure when the stored file is unreachable, and that a pending
preview placeholder keeps the row slot it arrived in.

Each test carries its `@scenario:<id>` tag and is self-contained: fixtures come
from the existing fake-model markers, Mongo seed helpers, or page-scoped route
stubs, and every seeded conversation is deleted again.

* 🏷️ fix: Name a Mermaid Blob After the Preview It Is

A share with snapshots disabled strips the download route from a
code-execution `.mmd` but keeps the cached `attachment.text`, and that text is
what `extractUtf8` truncated at 512 KB. With no route to the stored file the
panel can only serialize that cached copy, and the mermaid exception in
`getArtifactDownloadFilename` handed those partial bytes over as `flow.mmd` —
the stored file's own name, from a menu item labelled "Download source".

Mermaid now follows every other file-backed artifact: a serialized blob is named
`flow.preview.mmd`, and only the original-file route produces `flow.mmd`. A
model-authored diagram has no stored file to be a preview of, so it keeps its
own name.

* 🎹 test: Reach the Export Menu's First Item From Either Start

`supports keyboard export` opened the menu with Enter and pressed ArrowDown
once, assuming no item was active yet. CI observed the opposite — the first item
already active — so the single ArrowDown walked past SVG onto PNG and the test
failed there while passing locally (it failed the same way on the previous head).
`Home` lands on the first item from either starting point.

* 🔧 test: Fix the Fixtures Three Artifact Scenarios Ran On

The first verification run failed five of the seven scenarios on fixture defects,
not on product behavior:

- `artifact-trigger-rows` drove a fake-model marker
  (`E2E_ARTIFACT_SHOWCASE_REPLY`) that does not exist, so the reply carried no
  artifacts and no trigger rows. Both tests now seed the assistant message text
  through Mongo, the way the passing plain-text scenario does.
- `mermaid-source-download` waited for an `ArtifactRow` that a code-execution
  `.mmd` never renders — it renders an inline diagram whose trigger is "Open as
  artifact" — and its cached diagram embedded the literal truncation marker,
  which mermaid cannot parse, so the diagram failed to render at all. The cached
  copy is now a valid shorter diagram and the stored file keeps the sentinel that
  proves which bytes were saved.
- `pending-artifact-row-slot` gave the tool call's id to only one of its two
  attachments. Attachments reach a renderer through `mapAttachments` keyed by
  tool-call id, so the other one rendered nowhere; both now carry the same id and
  land in the one `artifact-row-group` whose ordering the scenario is about.

* 🪜 test: Count Four Authored Rows, Then the Mermaid One\n\nA mermaid fence is not a trigger row while it is inline: it renders the diagram\nwith an "Open as artifact" button and only becomes an ArtifactRow once opened.\nThe axis scenario now asserts the four authored artifacts stack as rows, then\nopens the diagram and asserts its row joins the same column.

* 📐 test: Measure the Row Axis in One Layout State\n\nOpening an artifact narrows the chat column, so comparing the mermaid row's x\nagainst a box measured before the panel opened reported an 8px offset that no\nuser sees. All five rows are now measured after the open, and the panel is\nlocated at page level, where it renders \u2014 not inside the message list.

* 🧩 fix: Give the Mermaid Row Its Own Filename and Download

A code-execution `.mmd` arrives wrapped in a header that prints the filename and
a download button, and that header stayed put when the diagram collapsed into
its trigger row — so the chat showed `flow.mmd` twice and left the download
outside the row that every other file-backed artifact keeps it in.

`Mermaid` now takes the file's download action and hands it to `ArtifactRow`,
and reports whether it is currently rendered as that row; `ToolMermaidArtifact`
drops its header while the row owns both. Opening and closing the diagram
therefore moves the filename and the download between exactly one place each.

* 🔖 test: Match a File-Backed Diagram's Own Trigger Id\n\nThe row ownership scenario looked for a \ trigger id, which\nonly a model-authored fence gets; a code-execution diagram's row carries the\nattachment's artifact id.

* 📱 test: Dismiss the Artifact Sheet Before Using the Row\n\nOn a phone the artifact panel opens as a sheet over the message list, so the\nrow's download button sits behind it. The scenario now closes the panel with its\nown control first, which also shows the trigger stays a row after closing.

* 🔔 fix: Raise One Toast per Failed Source Download\n\n\ already toasts "Error downloading file" before reporting a\nfailed delivery, and the export menu then raised its own — two notifications for\none press, on every ordinary 4xx/5xx, because React Query's refetch resolves\nwith an error result rather than throwing.\n\nA reported failure now only updates the menu's live region, which keeps the\nexport's own phrase for a screen reader; a thrown task still raises the toast,\nsince nothing else has spoken for it.

* 🔕 fix: Let the Download Layer Own Every Failure It Reports

The previous round split the failure notification by cause and left a hole in
the other direction: `useAttachmentLink` toasted only its empty-response
branch, so a thrown fetch — and the artifact hook's own blob failure — returned
`false` with nobody having spoken, and the export menu's live-region-only
announcement reached no sighted user.

One rule instead of two branches: whichever layer returns `false` has already
told the user. `useAttachmentLink` toasts its thrown branch as well, and
`useArtifactDownload` toasts when the blob path throws or there is nothing to
serialize. The mermaid export menu keeps announcing the export's own phrase in
its live region, so one press still raises exactly one toast — this time for
every failure, not just the polite ones.

* 🪄 fix: Hold the Row Slot and the Frame Before Blocking Work

Two fixes on the same theme — a row or a control that moves at exactly the
moment it is meant to stay put.

Writing salience-sorted resolved entries back into their arrival slots still
moved a pending row once it resolved: the placeholder joined the sort and could
pass an empty artifact that had been sitting above it. `attachmentSalience`
reads only `bytes`, which resolution does not change, so one sort over the whole
panel row — pending entries included — is stable across the transition and is
also less code.

The export deferral used `setTimeout(…, 0)`, which only moves the blocking SVG
work to another task; the timer can still run before the browser paints the
loading row, which is the whole point of deferring it. It now waits for an
animation frame and then a task, falling back to a timer when the tab is hidden
and serves no frames.

* 🏷️ fix: Keep the Recovered Filename on the Mermaid Row\n\n\ turns a sandbox-generated \ back into\n\, but that name lived only in the wrapper header. The moment the\ndiagram collapsed into its trigger row, the row title and its download button's\naccessible name fell back to \ — the internal filename.\n\n\ now takes the caller's display name for the row, and\n\ passes the one it already computed.

* 🗂️ fix: Open Each Artifact on the Tab Its Row Promises

A code-only artifact forces the panel onto Code, and that constraint was written
back into the panel's single `activeTab`. The next HTML, Markdown, plain-text,
React or diagram artifact then opened on Code while its trigger row announced
"Opens as a rendered preview" — the row was telling the truth about the artifact
and a lie about the click.

The constrained tab no longer leaks: the panel resets to the preview tab when a
different, unconstrained artifact is opened, so a manual Code choice lasts as
long as that artifact stays open and never follows the user to the next one.
`displayedTab` already derived the forced tab for rendering, so nothing else
depended on the write-back.
* fix: Reject aliased tool approval identities

* fix: Keep approval validation in TypeScript

* style: Format approval validation
* fix: preserve agent management upload IDs with E2E coverage

* test: clean up agent management upload fixtures
docs.librechat.ai/install/configuration/ai_setup.html 301-redirects
to the generic docs homepage; the page was renamed to
www.librechat.ai/docs/configuration/pre_configured_ai.
@pull pull Bot locked and limited conversation to collaborators Sep 14, 2026
@pull pull Bot added the ⤵️ pull label Sep 14, 2026
@pull
pull Bot merged commit b88d70b into innFactory:main Sep 14, 2026
1 check passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants