Commit graph

5190 commits

Author SHA1 Message Date
Danny Avila
1489623fa3
🎬 test: Record-Once/Replay-Forever Model Fixtures for Mock E2E (#15210)
* 🎬 test: Record-Once/Replay-Forever Model Fixtures for Mock E2E

The mock e2e lane's only credential-free model is a hand-authored script:
`fake-model.js` decides responses from ~60 `E2E_*` prompt markers. That
covers scripted shapes well, but no scenario replays a *real* recorded
provider conversation through the assembled chain, so real streaming
shapes — provider chunk cadence, reasoning deltas, usage metadata — are
only ever approximated.

This adds a record-once/replay-forever tier alongside the marker routing.

Record (`E2E_MODEL_FIXTURES=record`, needs a provider key): the run hook
appends a LangChain callback handler to every agent context's
`clientOptions.callbacks` instead of overriding the model, so the REAL
provider streams while each invocation's `AIMessageChunk`s serialize to
`e2e/fixtures/model-replay/<name>.jsonl` — text deltas, tool_call_chunks,
reasoning kwargs, and genuine usage metadata. Only the latest human text
is recorded for binding; system prompts and tool schemas never enter the
fixture.

Replay (default, keyless): `fake-model.js` consults `tryBindReplay` ahead
of marker routing, binding a conversation whose prompt matches the next
unconsumed invocation. The replaying model is not hand-assigned — it is
registered as SDK provider `librechat-e2e-replay` via `registerProvider`
and constructed through the SDK's own `initializeModel`, so registry
lookup, constructor clientOptions, and real `bindTools` all run the way a
live provider's would. Recorded chunks therefore stream through the same
createRun → graph → SSE → persistence chain.

Consumption is enforced rather than assumed: every invocation re-checks
its prompt against the recording, an invocation past the end of the
script throws, and a per-fixture ledger lets the spec assert at teardown
that every recorded invocation and chunk was drained. Streaming
incrementality is asserted from that ledger, not by sampling transient
DOM, which is a race by construction.

The credential-free profile is unchanged when not recording: the record
provider and its selector entry are template markers that stay comments,
and no existing spec's routing is touched (a fixture only binds on an
exact prompt match; everything else falls through).

Verified: record vs the real DeepSeek API 1 passed (15.2s); keyless
replay 1 passed twice (11.4s, 12.7s) with the ledger fully drained (2/2
invocations, 10/10 chunks, no overruns or mismatches); app-load,
completion, and chat 10 passed unchanged.

* fix(e2e): rebind a replay fixture from the top for a new conversation

The replay cursor is process-global while the web server outlives a
Playwright retry, so a fully consumed fixture left the retry unable to
bind its first prompt: it fell through to marker routing and failed
deterministically, burning every configured CI retry. A partially
consumed attempt failed the same way.

Binding now restarts the fixture when the incoming prompt matches its
first recorded invocation, resetting the ledger with the cursor so the
new attempt is judged on its own consumption instead of accumulating the
previous one's counts. Continuing an in-progress binding still outranks
restarting, so a fixture whose opening prompt repeats later in the script
advances rather than rewinding.

The over-consumption guard is untouched — it fires inside the stream when
the cursor passes the end, not at bind time.

* fix(e2e): close three replay-lane gaps found in review

Restart the recorder on a retry. Its state is process-global like the
replay cursor, so a failed attempt that had already recorded invocations
left the counter advanced: the retry appended 2/3 after 0/1, or kept the
previous attempt's `error` line, and the fixture was unusable for replay.
Recording now truncates and restarts when the opening prompt reappears,
mirroring the replay side's rule and its caveat.

Retain a consumed binding for the conversation that drove it. An extra
user turn past the final recorded invocation found no next invocation and
fell through to ordinary fake-model routing, so it was answered with a
mock reply: the over-consumption guard never ran and the already-drained
ledger still passed. Such a conversation is now recognized by its human
turns opening with the fixture's recorded prompts, and stays bound so the
stream raises the overrun. Continuing an in-progress binding still
outranks restarting, which outranks retaining a consumed one, so a
retry's fresh conversation rewinds rather than being read as an extra
turn.

Validate the fixture the recording actually wrote. Record mode honors
`E2E_MODEL_FIXTURE_NAME`, but the spec always inspected the committed
`deepseek-two-turn`; another name wrote elsewhere while the assertions
read the pre-existing file, and because the prompts are fixed the stale
answers could match and green a run that verified nothing it produced.

* fix(e2e): make replay binding correct for tool and subagent fixtures

Round two's retry and consumed-binding fixes both assumed one model
invocation per user turn. A turn that calls a tool breaks that: the model
is invoked again after the tool result under the same latest human
message.

Identify a retry by the conversation boundary, not the prompt. The
recorder ran per invocation and truncated whenever the opening prompt
reappeared, so a tool round trip looked like a retry and discarded the
recorded tool-call invocation. Restart detection now sits in
`installRecorder`, which runs once per `createRun`: a turn whose history
holds no prior human message begins a conversation.

Compare consumed bindings against user turns, not invocations. Several
recorded invocations can share one prompt, so a one-to-one comparison
could not recognize the originating conversation — invocations `[A, A, B]`
against history `[A, B, C]` failed on both length and elements, and the
extra turn fell through to the fake model with the drained ledger still
passing. Fixtures now carry their collapsed turn sequence.

Override the subagent model too. `graph.overrideModel` is not inherited
by child executors, so a fixture recording a subagent call — record mode
captures child invocations already — would leave the child on its
configured provider: an underrun, and a real provider request in a lane
that must stay keyless.

Reject ambiguous prompt matches. Binding order followed filesystem
enumeration, so a second fixture sharing a prompt could silently redirect
a scenario to the wrong chunks and ledger; the spec's choice never
reaches the server-side loop, so ambiguity fails instead of picking a
winner. Fixture identity is the file name for the same reason — a
recorded `meta.name` is descriptive, and trusting it let a copied fixture
collapse onto another's registry key and ledger.

Prove the recording is fresh. The spec removes the selected fixture
before driving, so a run whose hook never installed the recorder fails
instead of greening against a stale artifact whose answers still match
these deterministic prompts.

* fix(e2e): rewind a replay fixture at the conversation boundary

Consecutive invocations can share a prompt — a tool call produces exactly
that — so an attempt stopping mid-turn left the cursor on an invocation
whose text still equalled the opening prompt. Matching the cursor first
meant a retry's fresh conversation resumed after the tool call instead of
rewinding, consuming the post-tool invocation and silently replaying a
different script than was recorded.

A conversation boundary now outranks an in-progress cursor: a fresh
conversation whose prompt opens the fixture rewinds even when the cursor
would have matched. Continuing still outranks restarting within a
conversation, so a turn that calls a tool advances to its post-tool
invocation rather than rewinding on its own repeated prompt.

* fix(e2e): refuse cross-conversation binding and prove content streaming

A fresh conversation could steal a partly consumed fixture's later turn.
Only a conversation opening with the fixture's first prompt was treated
as a boundary, so after `[A, B]` had consumed `A`, an unrelated new
conversation whose first message was `B` matched the cursor, received the
recorded second-turn response, and advanced the shared cursor without
ever having driven `A`. A conversation start may now only rewind a partly
consumed fixture, never continue it; continuation within a conversation
is unaffected.

The incrementality assertion counted empty frames. Providers emit empty
initialization and usage-metadata chunks around the content deltas, so a
total chunk count above one was satisfied by a single delta: the previous
fixture's closing turn had four chunks and one content-bearing delta
carrying the whole answer, and both modes stayed green without proving
incremental assistant-content streaming at all. Fixtures now track
content-bearing chunks separately, the closing prompt asks for prose
rather than a number, and both modes require several content deltas on
that turn. Re-recorded: the closing turn now carries 28 content deltas.

* fix(e2e): scope record mode to the fixture spec

`E2E_MODEL_FIXTURES=record` replaces the fake-model hook globally, so an
unfiltered entry point such as `npm run e2e:mock` sent every spec under
specs/mock to the paid real-provider endpoint, while each fresh
conversation truncated and rewrote the one selected fixture — leaving an
artifact from whichever scenario happened to run last.

Record mode now matches only the fixture spec: an unfiltered recording
run lists one test instead of 203. Replay mode is untouched and still
collects the full suite.

* 🪪 fix: Bind Replay Fixtures by Conversation, Not Prompt Text

Prompt text was standing in for conversation identity, and three review
rounds found the same class of defect underneath it: a tool call repeats
a prompt across invocations, a retry repeats it across attempts, and a
resumed run has neither prompt nor history because `createRun` is rebuilt
with no messages while state comes from the checkpoint. Each fix in that
space created the next gap.

Thread the identity instead. `createRun` accepts a `conversationId` and
passes it to the run hook, which the agents controller supplies at both
call sites — the same value it already uses as the checkpointer's
`thread_id`. The field is optional and the hook is env-gated, so nothing
changes when the harness is not in use.

Binding then collapses to ownership. A fixture is owned by the
conversation that claimed it, and its cursor is authoritative wherever it
stands: an extra turn reaches the over-consumption guard rather than
falling through to the scripted fake model, and a resumed run keeps
replaying with no prompt to match. A different conversation may claim the
fixture only by opening it, which rewinds — what a Playwright retry looks
like. Everything else is refused, so an unrelated conversation can no
longer continue someone else's partly consumed script by repeating a
later prompt. The prompt is still re-checked on every real turn; only a
resume, which structurally carries no human message, is exempt. The
previous text-and-history rules remain as a fallback when identity is
absent.

The recorder keys the same way: a new attempt is a new conversation, so a
resume no longer truncates the fixture mid-turn and discards its
tool-call invocation.

Record summarization too. The summary provider runs on its own model with
its own callback list, so a scenario crossing the context-pruning
threshold recorded the agent's invocations but not the summariser's,
leaving a fixture that could not reproduce the pruned context.

* 🧾 fix: Harden Record Mode and Make the Rendered-Text Assertion Honest

CI caught what local runs had not: the committed fixture was never
replayed locally, because the record run overwrote it after the replay
check rather than before. Re-recording and replaying in that order is
what surfaced the rest of this.

The DOM assertion compared raw recorded text against rendered markdown.
The previous answer opened with `52.`, which Markdown renders as an
ordered-list marker, so those characters never appear in the DOM and the
match failed on all three CI attempts while replay itself was correct.
The closing prompt now asks for prose beginning with a word, a leading
enumerator is stripped before matching, and only a prose prefix is
compared.

Derived configs discarded the record-mode restriction. `config.redis.ts`
and `config.mermaid.ts` spread this config and then replace `testMatch`,
so `e2e:mock:redis` in record mode would still send its specs to the paid
provider. A restriction expressed as an overridable value cannot hold, so
record mode now refuses any config but the mock one.

Superseded recording callbacks could write across a reset. A failed
attempt with a provider call still in flight keeps its handler on the old
graph; after the retry reset, that call would allocate an invocation from
the new counter or append an `error` entry with a cleared mapping.
Handlers now carry the recording generation they were installed for and
ignore everything from an older one, and attachment dedupes against the
current generation so a graph carried across a restart is not left with
an inert handler.

* 🚧 fix: Make Summarization an Explicit Boundary, Not a Half-Feature

Recording summarization invocations without replaying them is worse than
ignoring them. Replay routes the agent model and subagents only, so a
recorded summarization entry takes a slot in the fixture sequence that
replay never consumes, and the next primary call reads the summariser's
chunks — a prompt mismatch or, worse, silently wrong content.

The attachment was also aimed at the wrong shape: the SDK reads
`summarizationConfig.parameters`, not `.parameters` nested under
`.config`, so the previous attempt would have attached to nothing in a
real run. Its test passed only because the test built the shape the code
expected rather than the shape the SDK provides.

Rather than ship a half-routed feature, recording now fails the moment
summarization runs, naming the reason. Both shapes are guarded so the
guard cannot miss the way the recorder did. Summarization fixtures need
replay routing for that model before they can be supported.

The derived-config guard added alongside it was itself broken: workers do
not carry `--config`, and the argument lookup fell through to
`process.argv[0]`, so every recording run aborted claiming the node
binary was an unexpected config. The flag is now located explicitly and
absence is treated as "not the process that parsed the CLI".

* 🔒 fix: Close the -c Config Alias and Pin the Recorder's Fixture Name

Playwright documents `-c` as an alias for `--config`, so record mode
launched as `playwright test -c e2e/playwright.config.redis.ts` slipped
past a guard that recognised only the long spelling. Both spellings and
both `=` and space forms are now parsed.

Accepting arbitrary fixture names also worked against the ambiguity
check. This spec drives one fixed prompt pair, so recording under another
name left two fixtures sharing those prompts; replay then refused to bind
either and the keyless lane stopped working — a successful documented
recording run could disable the suite it exists to serve. The spec now
records only the fixture it owns and says so when asked for another.

* 🔧 test: Record a Real Tool-Call Turn and Replay It Through the Tool Node

The fixture format carried `tool_call_chunks` and the binding advanced
through a turn's invocations, but nothing had recorded a real
tool-calling conversation end to end — the path was covered only by
hand-written synthetic fixtures, and it is the first one a new scenario
would exercise.

This records one: the provider calls the `remember_fact` MCP tool, the
tool runs, and the model is invoked a second time with its result. That
is the shape a single prompt cannot express — one user turn spanning
several model invocations, all sharing one prompt — so it is what proves
the turn-vs-invocation distinction the binding rules were built around.

Replay drives the real tool node rather than replaying its output, so the
tool executes again and the assertion checks its live result.

Two fixtures now coexist, which the record path had to grow for: the
config keeps an allowlist so an unknown name is still refused, record
mode collects every replay spec, and each spec records only the fixture
it owns and stands down for the others.

MCP tools reach the model under a server-qualified name
(`remember_fact_mcp_e2e-memory`); that qualification has changed before,
so the assertions match the base name as a prefix rather than pinning the
suffix.

Verified: record 1 passed (15.6s, real API) then replay 1 passed (13.6s)
against that fixture, ledger drained 2/2 invocations and 35/35 chunks;
both replay specs together 2 passed; app-load, completion, chat and
mcp-ephemeral 12 passed.
2026-08-25 18:29:55 -04:00
Danny Avila
290b8664d9
🧾 feat: Track Authoritative Agent Event Outcomes (#15213)
* feat: track authoritative agent event outcomes

* fix: isolate agent event outcome types

* fix: declare agent event handler result

* fix: simplify agent event status selection

* fix: preserve authoritative event outcomes

* fix: preserve terminal event evidence

* test: use completed run-step envelope

* test: scope deferred HITL question locator

* fix: settle every agent event terminal path

* style: sort terminal host action imports

* fix: fence agent event terminal evidence

* fix: recover agent event terminal settlement

* fix: scope terminal retry hints by generation

* fix: settle terminal host actions exactly
2026-08-25 18:20:57 -04:00
Danny Avila
6360821470
🪢 feat: Coalesce Bound Agent Event Bursts (#15208)
* 🪢 feat: Coalesce Bound Agent Event Bursts

* fix: Restore API Package Build

* fix: Keep Trigger Batches Within One Window

* fix: intersect trigger batch windows

* fix: fence trigger batch lifecycle

* style: sort trigger service imports

* fix: make batch requeue crash-recoverable

* fix: fence concurrent batch requeues
2026-08-25 10:48:56 -04:00
Danny Avila
6d499ba3ce
fix: Anchor Resumed Elapsed Time at the Generation's Real Start (#15204)
Some checks failed
Publish `librechat-data-provider` to NPM / pack (push) Waiting to run
Publish `librechat-data-provider` to NPM / publish-npm (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/data-schemas` to NPM / pack (push) Has been cancelled
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
*  fix: Anchor Resumed Elapsed Time at the Generation's Real Start

A reload emptied the Recoil anchor, so the indicator fell back to its
mount time and visibly reset to 0s over a run that had been generating
for much longer. The stream status the resume path already reads carries
the server-recorded generation start; the fill now prefers it, so a
reattached run reports real elapsed time. The fill remains fill-only:
a same-session reattach keeps its original ask baseline, and the
indicator's existing clamp absorbs any client/server clock skew.

* 🕰️ fix: Rebuild the Resumed Baseline From the Server-Computed Age

Codex round 1: anchoring at the server's raw createdAt compares two
clocks — a client behind the server froze the resumed reading at 0s for
the skew, one ahead inflated it. The status route now also reports the
generation's age computed on its own clock, and the client rebuilds a
clock-local anchor as Date.now() minus that age, so each machine only
ever compares to itself. Raw createdAt stays as the fallback for an
older server mid-rolling-deploy.

* 📥 fix: Compute Elapsed Age in TypeScript, Anchor It at Status Receipt

Codex round 2: the elapsed computation moves into packages/api as
getGenerationElapsedMs — the route now just delegates, keeping the
response contract type-checked and the /api surface a thin wrapper —
and the client subtracts the age from the moment the status response
arrived (dataUpdatedAt) rather than from apply time, so a slow history
fetch between receipt and apply can no longer shrink the reading.
Declined with rationale: a shared clock source across replicas — the
residual is inter-replica NTP drift, milliseconds against the
minutes-scale client skew this PR eliminates, and the helper gives any
future shared-clock upgrade a single home.
2026-08-25 09:35:10 -04:00
Danny Avila
d9e6250d05
🛑 fix: Separate Agent Event Backpressure From User Bans (#15200)
* 🛑 fix: Separate Agent Event Backpressure From User Bans

* fix: Address Agent Event Review Findings

* fix: Mirror Case-Insensitive Agent Control Routing
2026-08-25 09:27:35 -04:00
Danny Avila
e9dec7749a
🕶️ fix: Unhide Event Subagent Names on the Dark Surface (#15206)
The event subagent group's child rows are raw buttons with no text color
of their own, and the section root set none either, so the agent name
labels inherited straight from the unthemed black body color — invisible
on the dark surface (and silently off-token in light mode: pure black
where --text-primary is 33 33 33). The slug and status lines carried
explicit text-text-secondary, which is why only the names vanished.

Root gets text-text-primary, matching SubagentActivity and
SubagentThreadPanel, so every descendant inherits the theme role and the
rows' secondary lines keep their explicit overrides. Verified against
the live cascade: the label computes rgb(0,0,0) in both modes today and
the token color (236/236/236 dark, 33/33/33 light) with the root themed.
2026-08-25 09:25:06 -04:00
Danny Avila
877b9b2f1a
🐚 feat: Nonce-Based Content Security Policy for the SPA Shell (#14446)
* 🛡️ feat: Configurable Baseline HTTP Security Headers

Adds helmet's CSP-independent headers (HSTS, X-Frame-Options,
X-Content-Type-Options, COOP, CORP, Referrer-Policy) on every response,
with contentSecurityPolicy explicitly disabled. Every header that can
break a deployment is configurable, so there is no allow-list to go
stale the way #7377's hardcoded CSP directives did.

HSTS includeSubDomains defaults off rather than matching helmet's
on-by-default: it would otherwise pin every sibling subdomain to HTTPS
for a year in every visitor's browser, and undoing that requires
serving max-age=0 from each affected host.

* 🛡️ feat: Nonce-Based Content Security Policy for the SPA Shell

Adds an opt-in, per-response nonce CSP on the HTML response, resolved once
at startup so each request only mints a nonce and concatenates the header.
Report-only by default, since that is the rollout step #7377 skipped.

Rebase and correctness pass over #13226:

- Styles carry no nonce. A nonce in style-src makes browsers ignore
  'unsafe-inline', which would have blocked the <style> element the theme
  script injects at runtime, plus every style third-party components inject.
- frame-ancestors 'self' is now a default rather than opt-in, so enabling
  CSP actually covers the clickjacking half of #7110.
- CSP_SCRIPT_SRC_EXTRA now drops 'strict-dynamic', which would otherwise
  make browsers ignore the very hosts the operator configured.
- Nonce stamping runs after the query-devtools bootstrap injection so that
  injected script is covered too.

* fix: replace frame-ancestors instead of merging it

Merging the configured value into the default turned a deliberate
CSP_FRAME_ANCESTORS='none' into `frame-ancestors 'self' 'none'`, which
browsers resolve back to 'self'. Also bail out if the serialized policy
somehow lacks the nonce slot rather than emitting a header the shell
cannot match.

* fix: address Codex review findings on the CSP defaults

All five were real against LibreChat's actual runtime:

- CSP_REPORT_ONLY now only enforces on an explicit false/off/0/no. A typo
  or `1` previously fell through isEnabled() to enforcing, turning a
  config slip into a blocked SPA. Shares the parse helper with
  headers.ts via a new security/env.ts.
- Module preloads are stamped. A production client/dist/index.html
  carries 32 parser-inserted `<link rel="modulepreload">` tags, which
  'strict-dynamic' does not cover and 'self' cannot rescue.
- Stale nonce attributes are replaced rather than preserved; only the
  current response's nonce is authorized.
- worker-src allows data:, which Monaco's default CDN loader needs to
  bootstrap its workers (there is no loader.config() in the client).
- script-src allows 'wasm-unsafe-eval' for the HEIC upload path, which
  compiles WebAssembly through heic-to. Narrower than 'unsafe-eval'.

Verified against the real built shell: 4 scripts and all 32 preloads
nonced, stylesheets/icons/manifest and <style> untouched.

* fix: address second Codex round on CSP rollout controls

- SECURITY_HEADERS=false now disables CSP too. It is documented as the
  global kill switch, and an operator reaching for it to recover a shell
  broken by an enforcing policy must not be left with that policy on.
- The SPA shell is forced to `no-store` while CSP is enabled, ignoring
  INDEX_CACHE_CONTROL/INDEX_PRAGMA/INDEX_EXPIRES and warning when they
  are set. A cacheable shell pins one nonce across page loads and users,
  which is the whole thing a nonce policy defends against.
- Added CSP_ALLOW_WASM and CSP_ALLOW_DATA_WORKERS. The previous commit's
  .env.example claimed CSP_ADDITIONAL_DIRECTIVES could drop
  'wasm-unsafe-eval' and data:, but merging only ever appends sources, so
  the documented hardening step was impossible. These toggles make it real.
2026-08-25 09:18:52 -04:00
Marco Beretta
bf6144c9e1
🎛️ fix: Withhold the Seeded Model Catalogue Until Models Resolve (#15035)
`useGetModelsQuery` seeds from a static fallback config, so `modelsQuery.data`
describes a hardcoded model list both before the mounted fetch resolves and
after it fails outright. The agent builder read that seed as authoritative and
offered models the active server configuration never exposed.

Blank the catalogue until the mounted fetch actually succeeds, surface the
failure in the model panel instead of silently falling back to the seed, and
refuse to create an agent against a provider/model pair the resolved catalogue
does not offer.

Also wires the builder's orphaned `htmlFor` labels to the controls they name.
2026-08-25 08:58:02 -04:00
James Todaro
018775de07
🧾 fix: Honor Disabled Transactions on the Assistants Usage Path (#15100)
* 🧾 fix: Honor Disabled Transactions on the Assistants Usage Path

Thread the resolved transactions config through `recordUsage` from each of
its callers, so `transactions.enabled: false` is honored on the assistants
token spend path.

* 🧾 fix: Thread the transactions config through the vision-request caller

Address review: `ToolService.processVisionRequest` also records usage without
the resolved config, and `recordUsage`'s documented return type did not match
the function.

* 🧾 fix: Set the resolved transactions config after the usage spread

- provider usage could carry a `transactions` key that overwrote the trusted value
- matches the ordering the other `recordUsage` callers already use
2026-08-25 08:30:12 -04:00
Danny Avila
16dd677be4
🎨 style: Set Question Popover and Subagent Panel on the Sidebar Surface (#15201)
Both floated over the chat on surfaces one step too close to it — the
question popover on surface-secondary, the subagent thread panel on the
chat's own surface-primary. Both now use surface-primary-alt, the
conversation-list sidebar's role, verified in the running app: popover,
panel, and sidebar all resolve to the same computed background in dark
(rgb 23,23,23) and light (rgb 247,247,248). Inline question cards keep
surface-secondary deliberately — that is the tool-record family's
surface, and settled questions collapse into that family.
2026-08-25 08:29:42 -04:00
Danny Avila
2ef12b1e1d
🦺 feat: Configurable Baseline HTTP Security Headers (#14445)
Adds helmet's CSP-independent headers (HSTS, X-Frame-Options,
X-Content-Type-Options, COOP, CORP, Referrer-Policy) on every response,
with contentSecurityPolicy explicitly disabled. Every header that can
break a deployment is configurable, so there is no allow-list to go
stale the way #7377's hardcoded CSP directives did.

HSTS includeSubDomains defaults off rather than matching helmet's
on-by-default: it would otherwise pin every sibling subdomain to HTTPS
for a year in every visitor's browser, and undoing that requires
serving max-age=0 from each affected host.
2026-08-25 08:21:39 -04:00
Ravi Kumar L
3d2da403ce
🥛 fix: Drop Stale Saved Model Defaults in Builder Forms (#15179) 2026-08-25 08:18:17 -04:00
Danny Avila
4b113697b5
🔌 feat: Background Execution Toggles for Actions & Plugin Tools (#14407)
* 🧵 feat: Background Execution Toggles for Actions & Plugin Tools

* 🩹 fix: Resolve action background opt-in across encoded-domain forms and scope it per action

* 🧹 refactor: Resolve action domain in a single pass

* 🧩 fix: Merge Normalized Action Background Options

* 🪢 fix: Reconcile Action Background Aliases

* 🧭 fix: Harden Action Background Compatibility

* 🕰️ test: Allow Settled Task TTL Expiry

* 🧬 fix: Merge Refreshed Action Tool Registrations
2026-08-25 08:13:13 -04:00
James Todaro
4d246469dd
🧾 fix: Honor Disabled Transactions on the Abort Paths (#15099)
Resolve the transactions config from the request and forward it to both
abort write paths, so `transactions.enabled: false` is honored when a
generation is stopped.
2026-08-25 08:10:26 -04:00
Danny Avila
cc0111b3cf
📐 fix: Set the Elapsed Reading on the Column Its Neighbors Share (#15195)
The timer sat at the footer's flush left while everything around it is
inset 6px: the streaming dot pads (24 − 12) / 2 to center on the size-6
header icon's axis, and the hover-button glyphs that replace the timer
sit behind their own p-1.5. The same ps-1.5 inline-start inset lines the
reading up with the dot above it and the glyphs that follow it — measured
in the live app: timer x 382, dot x 382, first settled glyph x 382.
2026-08-25 07:52:37 -04:00
Danny Avila
862ebf3235
🪢 fix: Persist Failed Agent Turns Before Error Publication (#14118) 2026-08-25 06:50:46 -04:00
Danny Avila
a9d99b3771
📌 fix: Keep the Settled Turn Mounted Through Final Content Compaction (#15186)
* 📌 fix: Keep the Settled Turn Mounted Through Final Content Compaction

The agent aggregator writes content parts at provider-source indexes, so
the streamed array is sparse wherever a step produced nothing; the final
SSE event carries the persisted, compacted array. Adopting it verbatim
shifted every part after a hole, re-keying every index-derived React
identity: the settled message remounted wholesale, activity-phase groups
replayed their fold-in entrance, code panes re-highlighted, and the
thread visibly snapped up and down at the end of every tool-calling run.

finalHandler now pairs the compacted parts with their streamed
counterparts in order and stamps each with the index it streamed at
(`streamedIndex`, client-only); render keys read the stamp while all
coordinate logic (edit indexes, phase bounds, cursor) stays on the live
compacted positions the server persisted. Phase-segment keys also anchor
to their first defined part instead of the segment ordinal, since
phantom hole-only segments vanish at compaction and shifted every
segment after them.

* 🔁 fix: Carry Identity Stamps Through Re-Delivered Finals and Parallel Attribution

Codex round 1, both real:
- P1: a later final event can re-deliver an already-settled message as a
  fresh compact array (Assistants runMessages resync); index-aligned
  pairing returned it unstamped, wiping the previous settle's stamps and
  re-keying the older turn all over again. The pairing now carries the
  matched current part's stamp forward, so a settled turn keeps its keys
  through every subsequent final.
- P2: ParallelContentRenderer's sequential stretches invoked
  renderResumeAttribution with only the live index, so steer attribution
  nodes in parallel content still re-keyed at the swap. The stable key
  index now threads through both call sites; getPartKeyIndex moves to
  utils/messages beside the stamp writer it reads.

* 🧿 fix: Require Content Agreement Before Pairing Streamed Identity

Codex round 2 (P2, real): hide_sequential_outputs runs omit intermediate
parts from the final array, so a type-only match could hand the retained
output an omitted intermediate's identity — transferring its key and any
UI state. Non-tool pairing now requires content agreement: mutual-prefix
text for TEXT/THINK/ACTIVITY_LABEL (one side extending the other is the
same part observed at two moments), the Open Responses phase for TEXT,
and the label kind for activity labels — a blank reservation still pairs
with its filled label. Ambiguous shapes fall back to the pre-stamp full
re-key, which is honest for a final that visibly removes parts.

* 🪢 fix: Refuse Stamping When the Server Removed Content; Strip Stamps on Edited Reruns

Codex round 3, two of three real:
- Prefix agreement alone still mis-paired when an omitted intermediate
  happened to prefix the retained output. Pairing now also requires that
  no substantial streamed part is left over: leftovers mean the server
  removed content (hide_sequential_outputs), so every in-order pairing
  is suspect and the message re-keys plainly instead.
- An edited resubmission clones the settled (stamped) prefix and appends
  the rerun's parts at the prefix length; a retained stamp at or above
  that length collides with an appended part's key. The clone now strips
  the client-only stamps, reverting the retained prefix to physical
  identity for the rerun.

The third finding (content-segment keys under late-phase recovery) is
declined with rationale on the PR: user expansion overrides survive via
the message-wide expansion map with stable group ids, recovery is a
genuine restructure at the moment a phase materializes, and first-child
anchoring is the only choice stable under the two high-frequency events
(streaming appends and final compaction).
2026-08-25 06:26:17 -04:00
Danny Avila
ac2aef00f6
🫗 fix: Drain Quoted Excerpts Into Mid-Run Steering (#15175)
* 🧭 fix: Carry Quoted Excerpts Through Mid-Run Steering

"Add to chat" quote chips were dropped by every during-run steer path: the
steer POST had no quotes concept, so a composer-origin steer left the chip
staged (gluing onto the NEXT send) and a queued item steered into the live
run lost its quotes silently.

Quotes now ride the steer protocol end to end:
- POST + admission: `quotes` on the steer body, normalized like the chat
  route's (getReferencedQuotes caps), part of the idempotency fingerprint
  only when present so pre-existing receipts still replay.
- Injection: merged into the model-bound turn as Markdown blockquotes at
  both boundaries (text-only and media paths), mirroring prependQuotes.
- Persistence + replay: the STEER content part stores `quotes` separately
  from the typed text; stampSteerPartMedia re-merges them per turn (even
  with resendFiles off) via the SDK's transient media stamp, with the quote
  block folded into the token budget.
- UI: composer steers/interrupt-steers drain the chips (skill picks stay
  staged — they configure a NEW turn's run); SteerPart and the in-flight
  bubble render the same MessageQuotes reference blocks as user bubbles;
  queued/failed rows show a quote count; reconnect reseeds fall back to the
  server item's quotes when no local chip survives.
- buildMessages keeps its zero-await path to the parallel context kickoff
  via a synchronous stamp-target probe.

* 🧭 fix: Keep Quotes in the Client-Safe Steer Projection

toPendingSteer is the projection behind resume-state pendingSteers, abort
responses, and terminal leftover claims — dropping quotes there would lose
them on exactly the recovery paths the reconnect reseed's server fallback
relies on.

* 🧪 test: In-Flight Steer Bubble Renders Carried Quotes

* 🔁 fix: Re-Stage Quotes When a Pre-Quotes Replica Accepts the Steer

Codex flagged the rolling-deploy window: an old replica 202s a quoted steer
while dropping the excerpts, so the client cleared the chips for context the
model never received.

The 202 (fresh and receipt replay) now echoes quotesAccepted from the
DURABLE item; a missing echo on a quote-bearing composer-origin steer
re-stages the excerpts as composer chips — the pre-steer behavior, so they
ride the next send instead of vanishing — and strips them from the surviving
chip so a later terminal conversion cannot duplicate them. Queued-origin
steers keep quotes on the item, whose restore paths already return it
intact. The residual cross-version lost-ACK retry stays fail-closed as a
409 idempotency conflict (failed chip with retry controls).

* 🔁 fix: Close the Remaining Cross-Version Quote-Loss Windows

Codex round 2:
- Send now of a quoted queued item against a pre-quotes replica now
  re-stages the excerpts too (the row is consumed and the words inject
  bare, so the composer is their only remaining home); the strip clears the
  chip's captured origin copy so reclaims and terminal conversions cannot
  duplicate them.
- A quoted retry whose lost first ACK was accepted by a pre-quotes replica
  now REPLAYS that legacy receipt instead of 409ing: the stored fingerprint
  matching the quote-less hash of the same words proves the cross-version
  case, and the replayed 202's missing echo drives the re-stage. Different
  quotes against a quote-bearing receipt still conflict.
- TSteerAppliedEvent.part gains the quotes field (typed SSE consumers).

* 🧪 test: Drop the Stale Narrow SteerDrainOutput Alias

The spec's local intersection re-declared injectedMessages with
content: string, predating the SDK pin that declares the field natively
(content: string | MessageContentComplex[]). Under CI's clean install the
hook's BaseHookOutput is no longer assignable to that narrower alias; the
plain PostToolBatchHookOutput is the correct type for every drain/boundary
assertion. Verified against the published 3.6.16 dist and the local one.

* 🔁 fix: Honor the Generation Owner's Quote Capability End to End

Codex round 4:
- steerQuotesCapable rides job metadata (createJob + HITL resume rewrite),
  mirroring preemptCapable's owner-recorded pattern: an upgraded admission
  replica no longer stores quotes — or claims them accepted — for a
  generation whose older owning drain would silently drop them at
  injection. The missing echo drives the client re-stage, and a later
  capable handover cannot double-deliver restored context.
- Applied events reconcile dropped quotes: when a quote-less applied part
  settles a quote-bearing chip (the lost-202 ordering the ACK-echo path
  cannot see), resolveSteerChip and both reconnect settle paths re-stage
  the chip's excerpts before removing their only copy. mergeRestagedQuotes
  dedupe keeps every trigger idempotent for the same excerpts.

* 🔁 fix: Re-Read Quote Capability at the Last Moment and Cap Restaged Chips

Codex round 5:
- A HITL resume rewrites steerQuotesCapable without changing the
  generation's createdAt, so the enqueue fence cannot see a
  capable-to-legacy handover landing during admission's awaits. Re-read
  the owner's flag immediately before item construction (paid for only by
  quote-bearing requests); the residual between re-read and enqueue commit
  matches preemptCapable's documented race.
- mergeRestagedQuotes now respects the 10-quote contract with the staged
  chips winning: a restored tail that cannot ride the next send is dropped
  explicitly instead of rendering as a chip the submission would silently
  discard. MAX_QUOTE_COUNT moves to utils/steer as the single client
  source; QuoteButton imports it.

* 🔁 fix: Steer Quote Coverage for Preflights, Memory, and Single-Scan Stamping

Codex round 6:
- Stored-message policy inspection now extracts steer-part quotes as quote
  fragments (path /content/N/quotes/M), so conversation import and shared
  link preflights inspect the newly persisted field exactly like top-level
  message.quotes.
- The memory copy gets its own quote-merge stamp (text only, resendFiles
  false): formatAgentMessages ignores part.quotes, so without it a steer
  whose substance lives in its excerpt reached the chat model but never
  memory extraction.
- collectSteerStampTargets replaces the boolean probe: buildMessages
  collects once and hands the targets to stampSteerPartMedia, keeping the
  zero-await fast path without scanning the history twice.

* 🔁 fix: Redis Quote Plumbing, Conversion-Race Guard, and Quote-Bound Recovery Proof

Codex round 7:
- RedisJobStore.deserializeJob now restores steerQuotesCapable (the explicit
  mapper otherwise dropped it on every read, leaving quote steering inert in
  Redis deployments), with the round-trip spec extended.
- Both Lua parked-steer projections (terminal close + generation
  replacement) forward item.quotes, matching toPendingSteer — a lost final
  no longer strips excerpts from durable recovery in Redis mode.
- The no-echo restage reads the SURVIVING chip (reclaimRejectedChipQuotes):
  a terminal conversion that beat the delayed 202 already moved the quotes
  onto the queued follow-up, and re-staging them again double-delivered.
  Regression-tested with the conversion-before-ACK ordering.
- RecoveredSteerPayload binds normalized, order-significant quotes (builder,
  validator, TS matcher, and the Lua decode+matcher): a stale client
  presenting the same recoverySteerId with altered or missing quotes cannot
  consume the parked source. Quote-less sources keep matching quote-less
  recoveries.

* 🔁 fix: Execution-Bound Quote Capability with an Atomic Enqueue Predicate

Codex round 8:
- steerQuotesCapable becomes a transient assertion translated (at createJob
  and in ApprovalLifecycle.resolve) into steerQuotesExecutionId, valid only
  while it equals the LIVE providerExecutionId. A legacy replica winning a
  HITL resume rewrites the execution id without knowing the marker, so its
  stale assertion self-invalidates — a bare boolean could not be cleared by
  code that predates it.
- The fenced enqueue evaluates that equality atomically (all three Redis
  scripts decode-and-strip like the existing preemptCapable normalization;
  both InMemory sites mirror it) and returns the persisted item, so the
  quotesAccepted echo reflects exactly what was stored even when a handover
  lands between admission's read and the commit. The last-moment re-read is
  gone — the transaction is the authority.
- Tests: capable-resume re-binding, legacy-resume omit-not-clear
  invalidation, the admission-vs-handover race (capability read true, then
  execution rewritten before enqueue), and the Redis round-trip of the
  marker.

* 🔁 fix: Full Redis Parking Coverage and Loss-Moment Quote Restaging

Codex round 9:
- The two remaining Redis parking projections (terminal status CAS and
  stale-running cleanup) forward item.quotes — every field-picked steer
  projection now carries them (audited: 2 Lua 'projected' + 2 Lua
  'clientItem' + toPendingSteer).
- The ordinary no-echo ACK no longer re-stages: the steer has not injected
  yet, so the quotes stay carried on the pending chip. A quote-less applied
  event re-stages them at the actual loss; a terminal leftover conversion
  carries them onto the recovered row, whose normal send delivers quotes on
  any server — re-staging at the ACK let that leftover auto-send bare text
  while the excerpts glued onto an unrelated draft. Only the settled
  receipt replay (already injected, no future event) reclaims immediately.

* 🔁 fix: Legacy-Replayable Receipts with Separate Quote Identity

Codex round 10: an upgraded-first receipt stored a quote-inclusive
fingerprint no pre-quotes replica could recompute, so a lost-ACK retry
routed through one 409'd already-accepted words with duplicate-send
controls.

The durable fingerprint reverts to the quote-independent 3-field hash —
the one shape EVERY deployed version computes, replayable across a rolling
deploy in both directions — and quote identity moves beside it as
requestedQuotesFingerprint (of the REQUESTED quotes, pre any capability
strip, so an incapable-owner acceptance still replays its own retries).
Absent records (legacy-written or quote-less) accept any same-words retry,
preserving the round-5 rule; present records must match exactly, keeping
different-quotes clientSteerId reuse a 409 on quote-aware readers. Under
the keep-on-chip client contract a legacy replay's missing echo is
harmless — the excerpts stay carried on the pending chip.

* 🧪 chore: Re-Trigger CI After Dropped Workflow Events
2026-08-24 22:29:13 -04:00
Danny Avila
c0a55aa0f5
🧮 fix: Currency-Safe Single-Dollar LaTeX via Micromark Tokenizer (#15181)
Replaces the preprocessLaTeX string pass, whose currency allowlist missed
suffixes like "$2bn", letting SINGLE_DOLLAR_REGEX rewrite "$2bn to at
least $4bn" into $$-math. Single-dollar math is now a micromark text
construct (client/src/utils/latex.ts) registered by remarkSingleDollarMath,
so each span is decided during parsing with Pandoc-style boundary rules:
non-space after the opener, non-space before and no digit after the
closer, single-line, no backticks, opaque backslash escapes, balanced
braces, and fail-fast on an invalid close so a later price dollar can
never extend a span. Rejected spans stay byte-identical text, and code
spans, fences, and autolinks are structurally protected by the parser.

The LaTeX parsing setting now gates only this plugin; $$, \(...\), and
\[...\] continue to parse unconditionally via remark-math (aliased to
micromark-extension-llm-math, now mirrored in jest moduleNameMapper so
tests exercise the production tokenizer). katex/contrib/mhchem is loaded
with the markdown config, so \ce/\pu render properly instead of being
regex-mangled. splitMarkdown aligns its math options with the renderer.
2026-08-24 22:29:02 -04:00
Danny Avila
6988ff5d7b
✂️ fix: Unclip the Share Dialog's Public Role Menu (#15177)
PR #14734 replaced PublicSharingToggle's hand-rolled reveal (which set
overflow: visible while open) with the shared Collapse, whose permanent
overflow-hidden shears the non-portaled access-roles menu to a sliver.
Adds an opt-in overflowVisibleWhenOpen prop to Collapse — clipped while
closed and during the closing tween, unclipped once open — so in-tree
popovers can escape; the menu stays non-portaled because portaled menus
inside modal OGDialogs land aria-hidden and get focus-yanked shut.
2026-08-24 20:59:07 -04:00
Danny Avila
69e7c73614
🎛️ feat: Expose Authoritative Subagent Controls (#15169)
* feat: expose authoritative subagent controls

* fix: reconcile subagent control races

* fix: reconcile durable control conflicts

* fix: preserve authoritative subagent control outcomes

* fix: fence subagent controls to child thread

* fix: validate subagent control targets before routing

* fix: close subagent control boundary gaps

* fix: keep control reservations private

* fix: close subagent control admission gaps

* fix: preserve authoritative control history

* style: sort subagent control imports

* fix: preserve authoritative subagent control retries

* style: sort control state imports
2026-08-24 20:37:49 -04:00
Danny Avila
d641c398d5
🧳 fix: Port Subagent Control Receipt Writes to DocumentDB-Safe Operators (#15171)
* fix: harden subagent control receipt persistence

* fix: harden durable subagent control replay

* fix: await terminal control receipts on shutdown

* fix: close subagent control replay races

* test: type stale-owner transport fixture

* fix: quiesce durable subagent controls

* test: await subagent shutdown durability boundary

* fix: serialize durable subagent controls

* fix: fail shutdown on cleanup errors

* fix: report cancellable result availability accurately

* fix: fence subagent control receipt ownership

* fix: close distributed control receipt races

* test: type control receipt race fixture

* chore: require authoritative control receipts

* fix: close subagent control lifecycle races

* style: separate control reservation member

* test: harden subagent settlement wait

* fix: preserve authoritative control replay state
2026-08-24 20:05:39 -04:00
Danny Avila
afcf2e886c
📦 chore: bump @librechat/agents@latest to v3.7.1 (#15176) 2026-08-24 16:06:25 -04:00
Marco Beretta
649e68170e
🖼️ refactor: Consolidate Provider Icons Into a Single Registry (#15148)
* test: make useIsActiveItem observer assertions deterministic

The two attribute-flip tests mutated inside act() and then raced a 4 second
waitFor against MutationObserver delivery, so they failed once the client
workspace gained enough suites for a worker to stall past that budget.

Wait on actual observer delivery instead. The hook registers its observer on
mount, so it is ahead of the test's in delivery order and has already reacted
by the time the promise resolves. The new helper filters on data-active-item
because React writes data-active onto the same element when it re-renders, and
an unfiltered observer would resolve on that write instead.

This removes the last wall-clock dependence in the file, so the 20 second
jest timeout is no longer needed.

* feat: add canonical ProviderId vocabulary and resolver

* feat: resolve custom endpoint provider identity at config load

* feat: add provider icon registry data

* feat: add ProviderIcon and ProviderAvatar components

* feat: add provider icon resolution hook

* refactor: migrate direct icon lookups to the provider registry

* refactor: migrate composite endpoint icons to the provider registry

* refactor: render message provider icons from the registry

* refactor: remove the duplicated endpoint icon maps

The model selector was the last consumer of the icons map, so it now
resolves art through the provider registry like every other icon call
site. That leaves getIconKey with no callers, and the five icon map
types it depended on with no references, so all of them go too.

* fix: address Codex review findings on provider icons

Move brand tile colors onto theme tokens, accept relative image paths,
pass endpoint config into message icon resolution, keep Cohere padding
on landing only, render configured image URLs in provider-only
consumers, preserve the Gemma label, and publish provider assets with
the shared client package.

* fix: address remaining Codex findings on provider icons

Keep monochrome art white on branded avatar tiles, inline provider
assets as module data URLs so ProviderIcon works outside the SPA, and
recognize api.cohere.ai when resolving custom endpoint brands.

* fix: address the latest Codex review notes

Stop inlining provider logos into the shared bundle, keep agents and
assistants marks on group icons, reject CSS appended to brand
gradients, give brand tokens hex fallbacks for package consumers, and
treat data image URLs as configured artwork.

* fix: honor native provider and theme-controlled avatar contrast

Use an explicit custom-endpoint provider when host branding misses,
keep agents and assistants marks on model specs, and drive branded
avatar foreground from a theme token instead of a raw white class.

* fix: tighten brand validation and inherit SVG fill color

Forward the computed color class into provider SVGs, accept only a
single balanced gradient for brand backgrounds, keep provider
foreground hex-only, recognize relative image fragments, and preserve
percentage sizing in URLIcon fallbacks.

* fix: keep EndpointIcon hook-free and accept protocol-relative icon URLs

useMentions.ts invokes EndpointIcon({...}) as a plain function in seven
places, inside useMemo mappings and a React Query select callback, so the
useProviderIcon call added to it ran a hook outside a render and threw
"Invalid hook call" as soon as the mention list was built. It now uses the
hook-free resolveProviderIcon, and a spec pins the imperative-call contract
those call sites depend on.

isImageURL explicitly rejected protocol-relative URLs, so an endpoint or
model group configured with //cdn.example.com/provider.png fell through to
provider resolution and rendered the generic mark, where the removed
UnknownIcon rendered any nonempty custom iconURL. A leading // followed by
a host is now an image; a bare // or /// still is not.

The ConvoIcon spec's two cohere conversations move to one shared fixture,
since ProviderId.cohere is not an EModelEndpoint and a single-step
assertion to TConversation failed the client type check.

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

* fix: annotate themeBrandTokens for isolatedDeclarations

packages/client compiles with isolatedDeclarations, under which
`as const satisfies` is not an explicit type annotation, so the emitted
declaration could not be produced from the initializer alone.

This never surfaced before because the "Type check @librechat/client"
step only runs after "Type check @librechat/api", which was failing on
dev's Agents SDK issue and skipping it.

Annotated as readonly (keyof IThemeBrands)[] and frozen, matching
themeColorTokens directly above it. Both consumers only call .includes()
and .map(), so no literal tuple type is lost.

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

* fix: keep nested provider SVGs at their span's size

ProviderIcon sizes component art with an outer span carrying an inline
width/height, then rendered the SVG with cn('h-full w-full', classes).
Because cn is twMerge, a caller's own sizing class won that merge, so the
fraction applied twice: Landing passes size={41} with h-2/3 w-2/3, ConvoIcon
scales to a 27px span, and the SVG then took two thirds of that again, ~18px
where it used to be ~27px.

Only component-backed providers regressed. The asset branch has no wrapping
span, so its fraction still resolves against the 40px container.

Reordering the merge makes the span's size authoritative while leaving every
other caller class in place, including the [color:inherit] that branded
avatars forward. The img branch keeps resolving against its parent, so its
size is unchanged.

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

* fix: close the image-format and provider-host tables

Two allowlists that the refactor narrowed, fixed as sets rather than one
entry at a time.

isImageURL's extension list had grown by patch four times, each round
restoring one form the old renderer accepted. It now carries every format
browsers actually render, so avif joins apng, bmp, cur, jfif and the jpeg
spellings in a single pass.

The host table had no Azure entry, so an OpenAI-compatible endpoint on
team.openai.azure.com fell through to the generic mark; the custom schema
cannot express provider: azure, so host was its only signal. Both supported
Azure suffixes are added, and enumerating ProviderId against the table
surfaced Google as the same gap, which is added too.

Bedrock, mlx and ollama are the remainder and cannot be host-resolved:
bedrock's hostname is region-scoped under a shared AWS suffix, and the other
two are served from the operator's own machine. That is now recorded next to
the table and pinned by a test, so a provider added later without a host
fails rather than silently rendering the generic mark.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 13:00:38 -04:00
Danny Avila
3df046f29a
🎓 ci: Graduated E2E Spec Skipping, Wired Dark Until Armed (#15172)
Select emits e2e_skip from the decision's e2e.graduated (skippable-tier specs
whose clean-trial streaks meet the pre-registered bar, 2x where history-
coupled, >=3 distinct days — computed server-side from the shadow's per-spec
ledger). Dark by default: the output is empty unless repo var
CODEGRAPH_E2E_SKIP=on, and even armed it accepts only a well-typed pool-path
list from a non-fail-open decision (traversal segments rejected). Shard steps
subtract the skips from a git-derived run list — unknown names match nothing,
skip-everything falls back to full, and skipped specs still execute post-merge
in every full-suite vote run.

15 verbatim guard tests, scripts extracted from this YAML and executed against
fixtures: arm/disarm, fail-open, malformed/missing/non-array lists, traversal,
out-of-pool paths, unknown names, all-skips fallback, and the 2-real-skips
59->57 arg case. The traversal case caught a real regex gap pre-commit.
2026-08-24 11:53:50 -04:00
Danny Avila
f9c051f8ea
🪶 feat: Support Non-Persistent Controlled Themes (#15170) 2026-08-24 11:52:30 -04:00
Danny Avila
a997275902
🧾 feat: Persist Authoritative Subagent Control Receipts (#15168)
* feat: persist subagent control receipts

* fix: require control receipt persistence

* fix: preserve authoritative control history
2026-08-24 11:39:56 -04:00
Danny Avila
e0d5e11cdf
⏱️ feat: Show Elapsed Time Under the Streaming Response (#15167)
* ⏱️ feat: Show Elapsed Time Under the Streaming Response

A minimalist elapsed-time indicator (5s, then 1m 5s) occupies the footer
slot the hover actions vacate while a response generates, anchored to a
per-index submission-start timestamp so remounts (new-conversation id
hydration, navigation) never reset it. The once-per-second tick is
component-local state, so streaming rows never re-render on its account.

* 🧭 fix: Keep the Original Elapsed Baseline When Reattaching a Stream

Codex round 1: resume-on-load restamped the anchor at reattach time, so
navigating away from a still-streaming conversation and back restarted
the reading at 0s — the exact reset the atom exists to prevent. Resume
paths now leave the anchor alone: a same-session return keeps its ask
baseline, and a reload (atom empty) falls back to the indicator's mount
time, which is what the stamp produced anyway.

* 🪗 fix: Scope the Elapsed Timer to Its Own Generation, Localized and Spoken

Codex round 2, all four findings:

- The anchor is cleared on every terminal path (final, error, abort
  fallback), and resume-on-load only fills an empty one — so a run another
  client started never inherits a stale baseline, while a same-session
  reattach still keeps its original start.
- The indicator additionally requires the newest sibling position:
  latestMessageId follows the selected branch, so a settled older sibling
  paged to mid-regeneration satisfied the latest+submitting gate and got a
  counting timer under settled content.
- Visible digits now come from the shared run-step duration formatter
  (Intl.NumberFormat per locale), replacing the raw-number interpolations.
- The compact reading is aria-hidden with a spoken 'N seconds elapsed'
  equivalent beside it, per the house duration-label pattern; still no
  aria-live, so the tick never announces.
2026-08-24 11:36:36 -04:00
Danny Avila
5a8700643c
perf: Build the Memory Message Copy Only When Something Reads It (#15164)
buildMessages formatted every history row twice per turn — a prompt
copy and a memory copy — then discarded the entire memory payload
unless some row carried fileContext, which is the rare case. The
memory copy has exactly two consumers: that payload, and the canonical
recount of a row, where it is content-identical to the prompt copy
unless the row itself has fileContext. So the prompt copy is now the
recount surface for context-free rows, a fileContext row builds its
memory copy at recount time, and the full memory payload is assembled
in a deferred pass — same formatting, same per-row merge order — only
once a row has proven the payload will be kept. The common turn
formats each row once instead of twice and no longer allocates a
payload it throws away.

Also forwards the run's useLegacyContent to formatAgentMessages as the
new legacyContent option, inert on the current SDK release: once the
SDK change ships, text history is emitted pre-flattened so the
per-request legacy projection stops cloning every message and the
context meter's identity-based count reuse holds across the
projection.
2026-08-24 10:33:47 -04:00
Danny Avila
6f3d303985
📍 ci: Pin the Votes Ledger Results JSON to an Absolute Path (#15166)
First live full-suite vote run (32731087273, 198 passed) wrote
e2e/pw-results.json while the ledger step looked in the repo root and took
the fail-safe branch: zero trials logged. A relative
PLAYWRIGHT_JSON_OUTPUT_NAME resolves against the config directory, not cwd —
reproduced synthetically with the config in a subdirectory. Absolute
workspace path on both the reporter env and the ledger read.
2026-08-24 10:33:12 -04:00
Paco Cartones
bf1e13b806
🥁 fix: Compare TOTP Codes in Constant Time (#15157)
Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-08-24 09:10:03 -04:00
Danny Avila
f10fcd7d19
🎰 ci: Vote on the Full Mock Suite to End Phantom Spec Trials (#15162)
* ci: votes run the full mock suite; covered list from Playwright's own discovery

The vote workflow passed the merged PR's skippable tier as CLI path filters, but
playwright.config.mock.ts scopes discovery to testDir specs/mock/ — tier entries
outside that directory matched nothing, and the covered-list log line still
claimed them. Run 32701691037 proves it: a11y/keys/messages in the covered list,
zero of their tests executed, '120 passed' all from specs/mock/. The graduation
ledger was minting clean trials for specs that never ran.

Now every dev push runs the full mock suite (no path filters to mismatch), the
covered list is derived from playwright --list --reporter=json (git-enumeration
fallback over the same testDir), and each merge is one trial for every pool spec
— ~4x faster accrual toward the pre-registered graduation bars, plus the
post-merge Playwright safety net the jest workflows already have. Timeout 30->45
for the wider run; newest merge still cancels older votes; observe-only,
continue-on-error, kill switch CODEGRAPH_E2E_VOTES unchanged.

* ci: covered list from executed results, not discovery (Codex P1)

Env-gated suites (mcp-tool-list-changed needs E2E_MCP_LIST_CHANGED, enforced-
model-specs needs E2E_MODEL_SPECS_ENFORCE) are discovered by --list yet skip
every test under the vote job's default env — counting them as covered would
mint phantom trials, the exact class this PR exists to kill. The run now emits
line+json reporters and the ledger step derives covered from specs with at
least one non-skipped test outcome; no results json means no trials logged.
Verified against a synthetic suite: gated spec excluded, nested dirs handled,
crash branch logs nothing.
2026-08-24 09:04:24 -04:00
Danny Avila
18cc47128d
chore: bump agents sdk to v3.7.0 (#15163) 2026-08-24 08:54:58 -04:00
Danny Avila
6a7da61234
🥸 chore: Resolve Agents SDK Path Aliases That Masked Backend Types (#15160)
* 🐛 fix: Restore Agents SDK Type Resolution in Backend Type Checks

* 🐛 fix: Preserve Typed Prompt Callback Assignability

* 🐛 fix: Accept Agents Function Tool Calls in isImageVisionTool

* 🐛 fix: Prove the Run Step Wire Contract at Compile Time
2026-08-24 08:38:30 -04:00
Danny Avila
b6e3cf46d2
🕯️ fix: Decay Violation Scores With a Configurable TTL (#15153) 2026-08-24 08:36:21 -04:00
Danny Avila
c52ba4efdb
fix: restore provider typing against the Agents SDK declarations (#15161)
@librechat/agents publishes its declaration files with its internal @/*
path aliases unrewritten, across 112 files. types/llm.d.ts imports
Providers that way, so a consumer cannot resolve it, ProviderOptionsMap's
computed keys go unresolved, and keyof ProviderOptionsMap collapses to
number.

Through v3.6.15 that only degraded LLMConfig silently: provider was typed
as the unresolved Providers, so everything assigned. v3.6.16 made
SharedLLMConfig generic over that key union, turning provider into
number | RuntimeProviderName, which nothing real is assignable to. That is
the whole of the "Type check @librechat/api" failure on dev.

Declaring the one alias llm.d.ts needs restores the enum and the provider
key union, taking the package from 20 errors to 4. The remaining 4 were
genuine: custom-endpoint specs pass provider: 'custom', which widens to
string, and the SDK models a provider outside ProviderOptionsMap as
RuntimeProviderName.

Mapping every @/* alias instead was tried and rejected here: it unmasks a
backlog of roughly 114 latent errors elsewhere in the package, which is a
separate cleanup. The real fix belongs upstream, in what the SDK ships.
2026-08-24 03:28:59 -04:00
Danny Avila
8773b36eec
🎽 fix: Commit Subagent Roster Selections to Form State (#15154)
* fix: persist subagent selections synchronously

* test: verify subagent roster form state

* style: sort subagent roster test imports
2026-08-24 03:21:48 -04:00
JOJO
092bc583a8
📭 fix: Detect Agent List Pages in useHasData (#15156)
* 🐛 fix: Detect `AgentListResponse` data in `useHasData`

The marketplace agent queries return `AgentListResponse` pages whose
agents live under the `data` field, but `useHasData` only checked for
a non-existent `agents` field, so it always returned `false` for real
agent list pages. Check the `data` field first so cached list pages are
recognized as meaningful data.

* fix: preserve SmartLoader type narrowing

* fix: retain cached agents during refetch

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-24 02:45:59 -04:00
Danny Avila
c7e8b45419
🪶 refactor: Polish Event Subagent Activity (#15152)
* fix: polish event subagent activity

* chore: satisfy static checks

* fix: close subagent activity review gaps

* test: satisfy activity selection types

* fix: preserve subagent group layout scope

* fix: close subagent activity polish gaps

* fix: narrow edited activity anchor id

* feat: present subagent turns as one thread

* fix: keep subagent timeline pinned

* fix: render sparse assistant content

* fix: retain sparse initial activity cursor

* fix: bound continuous subagent history

* fix: type timeline prefix

* chore: sort timeline imports
2026-08-24 02:24:30 -04:00
Danny Avila
9cee6f97cc
🧩 chore: Bump Agents SDK to v3.6.16 (#15151) 2026-08-23 23:20:31 -04:00
Marco Beretta
4d8f145526
⏱️ test: Make Active Item Observer Assertions Deterministic (#15147)
The two attribute-flip tests mutated inside act() and then raced a 4 second
waitFor against MutationObserver delivery, so they failed once the client
workspace gained enough suites for a worker to stall past that budget.

Wait on actual observer delivery instead. The hook registers its observer on
mount, so it is ahead of the test's in delivery order and has already reacted
by the time the promise resolves. The new helper filters on data-active-item
because React writes data-active onto the same element when it re-renders, and
an unfiltered observer would resolve on that write instead.

This removes the last wall-clock dependence in the file, so the 20 second
jest timeout is no longer needed.
2026-08-23 23:16:14 -04:00
Danny Avila
0a9cf6c1f6
perf: Plain-JSON Memory Cache and an Idle Backoff for the Trigger Poll (#15144)
*  perf: Use Plain JSON for the In-Memory Cache Store

Every read from the in-memory Keyv fallback paid @keyv/serialize's
Buffer-aware reviver: 0.33ms for a 12KB config-shaped value against
0.038ms for a plain JSON round trip, on every config, role, and model
lookup a request makes. An instrumented sweep of the e2e suite — the
serializer wrapped to flag any value carrying the Buffer marker, armed
in all seven server and fixture processes — found no namespace ever
caching a Buffer.

Plain JSON keeps the semantics readers already rely on: values are
copies, never references into the store, and dates still come back as
ISO strings. A Buffer would now round-trip as its JSON form instead of
reviving; the new spec pins that as the documented contract. The Redis
and file-backed stores are untouched.

*  perf: Back Off the Trigger Delivery Poll While the Queue Is Idle

The delivery engine issued a claim findOneAndUpdate every second per
replica whether or not any trigger existed — ~86k no-match queries a
day on an idle deployment. The poll now doubles its interval after
each empty claim pass, capped at maxIdleTickMs (default 15s, floored
at tickMs), so an idle replica settles at four queries a minute's
worth of chatter down to one per fifteen seconds.

Nothing that has work waits: enqueues and finished deliveries already
call wake(), which now also snaps the streak and the poll timer back
to the base cadence before claiming. The only latency this can add is
cross-replica pickup of a trigger enqueued elsewhere while this
replica is fully idle — bounded by the cap.

The next timer delay is computed after each claim settles, so the
backoff is never a step behind the queue's state.

* 🎯 fix: Never Let Anything but a Confirmed-Empty Queue Advance the Idle Backoff

Two review findings, both real. A failed claim pass proves nothing about
the queue, yet it advanced the idle streak exactly like a confirmed-empty
one — repeated transient database failures would have stretched recovery
polls toward the ceiling and left due deliveries waiting after recovery.
Failures now reset the streak, restoring the pre-backoff status quo of
one-second retries through an outage and immediate catch-up after it.

And service.requeue(), which revives a dead letter straight in Mongo,
never woke the engine, so a revived delivery could wait out a full idle
interval that the old fixed poll bounded to a second. A successful
requeue now wakes the engine exactly as the enqueue path does; a requeue
that revived nothing wakes nothing.

* 🎯 fix: Never Sleep Past a Known Eligibility Time

A delivery that exists but is not yet eligible reads as an empty queue
to the claim pass, so a retry or defer scheduled a few seconds out could
wait out the full idle interval that the old one-second poll bounded
tightly. The engine computes every one of those future availableAt
times itself — retries, defers, and the ordering recheck — so it now
records the earliest of them and the idle timer never sleeps past it;
the marker clears once reached. The service routes future-dated
enqueues and requeues through the same noteEligibleAt seam and wakes
immediately for due ones, as before.

Deliveries delayed by another replica remain bounded by maxIdleTickMs,
the same class of tradeoff as cross-replica enqueue pickup.

* 🎯 fix: Track Every Eligibility Deadline, Not Just the Earliest

A single next-eligible slot discarded later deadlines: with retries due
at t1 and t2 > t1, reaching t1 cleared the only timestamp and the t2
delivery degraded back to idle-poll pickup, up to maxIdleTickMs late.
The engine now keeps a sorted, deduplicated, bounded list of the future
availableAt times it has seen, prunes entries as they come due, and
re-arms the timer whenever a new earliest arrives — including while the
timer is already sleeping toward the idle cap, which the previous
insert-at-head check missed for an empty list. On overflow the latest
deadline is dropped and that delivery falls back to the capped idle
poll, the same bound that covers deliveries delayed by other replicas.
2026-08-23 23:15:26 -04:00
Danny Avila
345c7aea7d
🎨 ci: Gate Frontend Jest on Codegraph Selection (Stage 1.5) (#15145)
* 🎨 ci: Gate Frontend Jest on Codegraph Selection (Stage 1.5)

* ci: a malformed FILES decision runs FULL, never skips (Codex)

* ci: dev-push runs never cancel each other (Codex P2)

* ci: workflow-file push baseline, cancellable gated jobs, pull-requests read (Codex r4)

* ci: selected paths must live under their workspace, else FULL (Codex r5)

* ci: drop stale selected paths, run FULL when none exist (Codex r6)
2026-08-23 23:03:21 -04:00
Danny Avila
d85bea41cc
🧪 ci: Mock the Parent Subagent Index Handler in Convos Route Tests (#15146) 2026-08-23 22:54:19 -04:00
Marco Beretta
dd85c6d6d0
🪹 feat: Shared Empty State for Side Panels (#15123)
* feat: shared empty state for side panels

Bookmarks and Memories each hand-rolled the same empty state: the same bordered
card, the same circular icon surface, the same title and caption sizes, written
out twice. Schedules had none at all, so an account with no schedules got a bare
list with nothing to explain what the panel is for.

One EmptyState primitive in packages/client, taking an icon, an optional title
and description, and an optional action. Bookmarks and Memories move onto it with
no visual change and no copy change. Schedules gets a real empty state, and an
error state with a Retry action, so a panel that failed to load offers a way out
instead of looking empty.

A description with no title takes the title's size rather than the caption's:
where it is the only line, it IS the message.

* fix: drop the create hint for roles without schedule create access

The panel already hides its create button behind hasCreateAccess, but
the empty state still told a USE-only viewer to create a schedule it
offers no way to create. The invitation now renders only when the
capability does.

* fix: suppress the create hint when the quota already blocks creation

A maxPerUser of 0 disables the create button on an empty list, so the
empty state must not say to create one either; the hint now follows the
same effective gate as the button.
2026-08-23 18:56:34 -04:00
Danny Avila
fc2b8584c4
📇 feat: Surface Event Child Activity Through a Bounded Parent Index (#15142)
* feat: surface event-driven child activity

* fix: keep child task aggregation documentdb-compatible

* fix: address event activity review findings

* test: provide markdown message context defaults

* fix: report bounded child history truncation

* fix: preserve current child activity state

* fix: preserve durable event child activity

* fix: handle missing task timestamps

* fix: keep active event snapshots live

* fix: preserve event activity across valid anchors

* fix: close event child activity gaps

* fix: preserve event activity across resume
2026-08-23 18:50:00 -04:00
Marco Beretta
8a118c7cb3
⏱️ feat: Shared Time Picker for Schedule Times (#15122)
A schedule's time was three dropdowns side by side: hour, minute, meridiem. That
is three controls for one value, it cannot be read at a glance, and the minute
list was a fixed set of four with the stored value bolted on, so a schedule
already running at :07 could be kept but never chosen.

They become one TimePicker: hour, minute and, where the clock format calls for
one, meridiem, as scrollable columns behind a single trigger showing the selected
time. An hourly cadence gets MinutePicker, the same control with its other
columns dropped, so it reads as the same widget rather than a different one. Both
live in packages/client with their wording passed in as props, so the primitive
carries no translation keys of its own.

Not `<input type="time">`: the browser owns its rendering, and it cannot be
brought in line with the rest of the form.

`hour12` is a required prop rather than a locale-derived guess. The app has
already resolved its Clock format setting, and re-deriving the answer inside the
picker would let it disagree with the summary printed beside it.

The trigger names its selected value as well as its field: `aria-labelledby`
replaces a button's child text, so pointing it at the label alone announced
"Time" and left a screen reader user unable to tell what was selected without
opening the columns and reading them. The columns are a roving-tabindex
radiogroup, arrow keys wrap, and the selected row is scrolled to the middle of
its column on open.

The popover is deliberately not portaled. A Radix dialog sets `pointer-events:
none` on the body while open, so a popover portaled out of it renders correctly
but receives no clicks or wheel events, and its focus trap puts the content out
of tab order too.

Hour and minute are set in one change. Behind separate fields a half-applied edit
could submit a time the user never picked, and the form now carries the hour as
the 0-23 value the cadence stores rather than a 12-hour value plus a meridiem it
has to recombine.
2026-08-23 22:47:33 +00:00
Marco Beretta
7834ebab33
🕰️ feat: Clock Format and Week Start Preferences (#15121)
* feat: clock format and week start preferences

Times were written in whatever convention the browser locale implied, and the
week always started on Sunday. Neither is right for a large part of the user
base: most of Europe reads a 24-hour clock and starts the week on Monday, and a
user running an English interface in a region that does either is currently
given the American convention with no way to change it.

Two General settings, Clock Format (System / 12-hour / 24-hour) and Week Starts
On (System / Sunday / Monday). Their System branch reads the runtime locale
rather than `i18n.language`, which is normalized down to a translation bundle:
`en-GB` and `en-AU` both become `en`, which is exactly the regional part these
two settings depend on, and reading it would report a 12-hour clock and a Sunday
week to a British user.

Week start is typed on the same 0-6 Sunday-first scale the schedule cadence uses
rather than being narrowed to Sunday/Monday, because the System branch reports
whatever the locale says and several (ar-EG, fa-IR) start the week on Saturday.
Engines without `Intl.Locale.prototype.getWeekInfo` fall back to a short list of
Sunday-first regions with Monday, the ISO 8601 default, otherwise: this is a
display default the toggle can always override, so an imperfect fallback degrades
rather than breaking.

Both settings are stored per browser. They describe how this device reads a
clock, which is a property of where someone is sitting rather than of their
account, and a user who moves between a European desktop and a US phone wants
each to read its own way.

Applied to message timestamps, the schedule dialog and card, key expiry and
refill dates, prompt and agent version dates, memory dates, and project chat
lists. The weekday order also drives the schedule dialog's day pills and the way
a weekly cadence reads back, so a wrap-around selection of Sat+Sun+Mon reads
"Monday, Saturday, Sunday" in a Monday-first week instead of "Sunday, Monday,
Saturday".

Dropdown now names its selected value as well as its field label. `aria-labelledby`
REPLACES the trigger's own text, so pointing it only at the caller's label left
the selected value unannounced, which these two settings are the first consumers
to hit.

* fix: teach the week-start fallback the Saturday-first regions

The no-week-data heuristic could only answer Sunday or Monday, folding
ar-EG to Sunday and fa-IR to Monday when CLDR says both start on
Saturday, and the selector offers no explicit Saturday override to
recover with. It now carries CLDR's Saturday-first territories, and the
UAE moves off the Sunday list to the Monday default, where CLDR put it
when its weekend moved to Sat-Sun. The fallback tests delete the
engine's week data for their duration, so they exercise the heuristic
on every engine instead of skipping wherever getWeekInfo exists.

* fix: infer likely regions for bare language tags and stop rebuilding clock formatters

A runtime that reports a language-only locale (bare ar or fa) carried no
region for the week-start heuristic, so those users fell to the Monday
default even though maximize() knows their likely region starts the week
on Saturday. The heuristic now maximizes before defaulting.

The runtime locale and each locale's meridiem answer are also cached at
module scope: every message timestamp mounts useClockFormat, so the
uncached path built a fresh Intl.DateTimeFormat per rendered message,
hundreds in a long conversation, even when the preference ignores the
locale entirely.

* fix: keep the Maldives on Friday in the week-start fallback

CLDR's lone Friday-first territory was in neither fallback set, so
dv-MV (and bare dv, which maximizes to MV) fell to Monday on engines
without week data, with no Friday override in the selector to recover
with. The three per-day sets consolidate into one region-to-day map.

* fix: complete the Sunday-first fallback from CLDR week data

The hand-picked ten Sunday-first regions left the System preference on
Monday for en-IN, id-ID, bn-BD, ur-PK, th-TH and the rest of the long
tail on engines without week data. The list is now every territory whose
und-XX week does not start Monday per CLDR, deprecated codes included,
with a note on how to regenerate it when CLDR moves a territory.

* fix: mock message context across markdown test suites and prevent global plugin cache leak
2026-08-23 18:41:14 -04:00
Danny Avila
b421f900dd
🗳️ ci: Log the Exact Spec List in Codegraph E2E Votes (#15143) 2026-08-23 18:26:34 -04:00
Danny Avila
d864597731
perf: Append Saved Message Ids Instead of Rebuilding the Conversation Array (#15141)
*  perf: Append Saved Message Ids Instead of Rebuilding the Conversation Array

Every saveConvo read every message id in the conversation (sorted) and
wrote the array back onto the document — twice per chat turn, O(n) in
conversation length, from a write path. The turn's savers know exactly
which message they just wrote, so they now pass it as
metadata.appendMessageIds and saveConvo $addToSet-s it, skipping the
read and the full-array rewrite. Every save without the option — titles,
archive, fork, import, threads — still rebuilds from the database, which
remains the heal point for the drift that message deletion has always
left behind (deletes never ran saveConvo).

The array's consumers read presence or length, or use it as an
optimistic cache placeholder, so incremental maintenance is
behaviorally identical; on traced turns the array stays exactly equal
to the messages collection.

Per-turn queries: 15 -> 13 (two Message.find gone), and the growing
array payload no longer crosses the wire twice per turn.

* 🎯 fix: Brand the Lineage-Only Resolved Conversation Instead of Guessing by Shape

The resolved-conversation files fast path treated an absent files
property as unresolved so the lineage-only partial from a bound
agent-event continuation could not silently hide a conversation's
uploads. But MongoDB never stores an empty files array, so nearly every
real conversation also lacks the property and the fast path never fired
— a follow-up turn on an upload-free conversation still paid the
getConvoFiles round trip.

The synthesized partial is the one object that cannot speak for the
database, so it now carries an explicit symbol brand
(PARTIAL_RESOLVED_CONVERSATION, non-serializing and invisible to key
iteration), and a stored document without files means what it means:
no files. Traced follow-up turns drop from 14 queries to 13.

* 🧪 test: Expect the Appended Message Id in the Route's saveConvo Metadata

messages-get.spec.js pins the exact metadata POST /api/messages passes to
saveConvo; the route now forwards the saved message's _id as
appendMessageIds, which is the behavior the append path depends on.
2026-08-23 16:52:44 -04:00