[pull] main from danny-avila:main - #577
Merged
Merged
Conversation
…5040) * fix(schedules): harden limits and reconciliation * docs(schedules): align reconciliation invariants * fix(schedules): preserve prompt editor contract
CLAUDE.md pointed at /home/danny/agentus for the @librechat/agents source. That path resolves on one machine, so for every other contributor the line occupies context on every turn while pointing nowhere. Replaced with the public repository URL. AGENTS.md opens with "See CLAUDE.md", which makes CLAUDE.md the source of truth, but it carried an auth cache invalidation rule that appears nowhere in CLAUDE.md. Claude Code reads CLAUDE.md and not AGENTS.md, so contributors using it never received a rule about serving stale req.user. Moved that rule into a Backend Rules section in CLAUDE.md, mirroring the existing Frontend Rules section, and left AGENTS.md pointing at it the same way its theming paragraph already does. Closes #15045
* fix: hide subagent threads from conversation lists * fix: exclude child threads from search indexes * fix: fence child search index cleanup * fix: preserve private search exclusion markers * fix: wait for cleanup through Meili client * fix: preserve lean message update results * fix: make search cleanup acknowledgments retryable * fix: complete child search cleanup migration
…e from Startup (#15051) * 🛑 fix: Confirm Scheduled Stops and Separate Terminal Scheduler Failure from Startup Fixes #15042, fixes #15043. `resume.js` inferred a confirmed stop from the ABSENCE of `failureReason`, but `abortJob` had four `success: false` paths that returned no reason at all. Those settled the occurrence as `interrupted` and pruned the checkpoint on aborts that never landed — including one where a REPLACEMENT generation owned the conversation, which pruned the successor's checkpoint. Every `success: false` return now names itself (`job_not_found`, `already_settled` added alongside the existing `generation_replaced` / `job_still_active`), and a single canonical `isStopConfirmed` predicate decides whether durable state may be settled. `already_settled` confirms a stop — `awaitProviderDrain` has proven the provider segment can no longer persist — so a permanently terminal generation is not answered with a retry loop. Separately, a schedule engine that failed to arm advertised its permanent outage as a transient 503 with `Retry-After`, so a client obeying it would poll forever. Readiness is now tri-state (`starting` / `armed` / `unavailable`): the retry contract applies only while arming is genuinely pending, and a failed arm returns a terminal `SCHEDULES_UNAVAILABLE` with no `Retry-After` and an error-level log. * 🏷️ fix: Declare Schedule Write Gate Return Types `--isolatedDeclarations` requires an explicit return type on the exported factory and on the middleware it returns (TS9007). Adds a named `ScheduleWriteGate` type matching the existing `ShareMiddleware` shape.
* fix: close child thread read and cleanup gaps * fix: preserve child search cleanup invariants * test: complete mocked update result * perf: parallelize scoped message reads * fix: close child thread compatibility gaps * test: expect preserved cleanup failure * fix: reconcile legacy Meili cleanup markers
* 🔌 refactor: Extract Git Repository Adapter From Skill Sync Skill sync interleaved GitHub REST calls with orchestration that is not GitHub-specific in any way — discovery, import limits, upsert and stale reconciliation, status accounting. Adding a second provider meant either threading provider branches through that orchestration or forking it. Introduces `GitRepoAdapter` — `resolveCommit`, `fetchTreeEntries`, `fetchFileContent` over a normalized `RepoTreeEntry` — and moves the GitHub REST client behind it. The runner keeps its GitHub source typing; only the transport moved. No behavior change: every pre-existing sync test passes untouched, still driving real GitHub responses through the mocked `fetchFn`. * 🔧 fix: Export GitHubRepoAdapterConfig alongside GitRepoAdapter Self-review: the exported `createAdapter` dep names a config type that consumers could not import, leaving half its signature unnameable.
* 📱 fix: Recover the Stream After a Mobile Tab is Backgrounded `sse.js` is XHR-based, so a mobile browser that backgrounds or freezes the tab cancels the in-flight request and the transport reports `abort`, not `error`. The abort listener assumed every abort was one this hook issued and went idle — leaving the pane holding whatever partial content arrived before the switch, looking finished, with nothing left to re-read the conversation: `useResumeOnLoad` only runs when entering a conversation, and the messages query never refetches on focus, mount or reconnect. An abort reaching that listener before any terminal event and outside a reconnect or handoff is a user-agent cancellation — every close this hook owns is already fenced by the lifecycle signal, `reconnectAttemptRef`, the handoff flag or `finalReceived`. Schedule the same backoff reconnect the transport-error path uses so the existing recovery adjudicates: a live job replays what was missed, a finished one 404s into the durable refetch. A frozen tab can also lose its stream with no event at all — an intermediary ends the response body, XHR reports an ordinary load, and sse.js dispatches nothing. Re-attach on `visibilitychange` when this subscription's transport is already closed with no terminal event behind it. * 🩹 fix: Retire a Subscription the 404 Reconcile Already Terminalized The foreground re-attach keyed only on `finalReceived`, but the two terminal recoveries that do not ride a frame — the 404 and retry-ceiling reconciles — never set it. The 404 path also leaves the submission installed and `sseRef` pointing at the closed attachment, so every one of its guards still passed: switching apps after the exact recovery this PR is about would resubscribe to a stream the server no longer has, 404 again, and republish an `aborted` run-end into the queue drain on each return. Fold the dev-only close flag into a `subscriptionRetired` marker that both terminal reconciles set, and gate the abort and foreground paths on it alongside `finalReceived`. * 🔌 fix: Fence Owned Closes Per Connection and Pin the Transport Contract `reconnectAttemptRef` is shared across the whole reconnect ladder and stays raised from the moment a retry is scheduled until the replacement connection opens. The abort listener read it as "this close was ours", so a user agent that cancelled the replacement before it opened — the ordinary case when the retry timer fires while the tab is still backgrounded — was attributed to the previous connection's deliberate close, and recovery stopped there with the stream detached. Ownership is per connection, so track it per connection: every close this subscription performs goes through `closeStream`, and the listener keys on that instead. An unsolicited abort is a dropped connection by every meaningful measure, so hand it to the transport-failure path verbatim rather than running a second ladder beside it. That path already climbs its backoff, adjudicates the retry ceiling against durable status, and terminalizes into the durable refetch — none of which the hand-rolled branch did, which is how the replacement's failure could dead-end in the first place. The mock transport now fires `abort` from `close()` like the real one, so our own closes are exercised through the same listener rather than around it, and a contract spec pins the two sse.js behaviours the recovery reads: a response body that merely ends dispatches neither error nor abort but does mark the connection closed, and a cancelled request dispatches abort.
) Every Playwright job spent a flat 90s on `npx playwright install ffmpeg`, and none of them ended up with a usable ffmpeg. The 2.3MB download finishes in under a second; extraction then hangs until `timeout -k 10 90` reaps it (exit 124, masked by `continue-on-error`). That is a Node 24.16.0 readable-stream change (nodejs/node#62557) colliding with yauzl/fd-slicer never firing `close` after EOF, which hangs extract-zip. It leaves a truncated `ffmpeg-linux` — 5,055,201 bytes against the zip's declared 5,101,056, segfaulting on exec — and no INSTALLATION_COMPLETE marker, so Playwright treated ffmpeg as uninstalled. `video: 'on-first-retry'` has therefore never worked in CI, and every first retry of a flaky test died in browserContext.newPage: exactly the failure the step existed to prevent. Upstream fixed it in Playwright 1.60.0 (microsoft/playwright#40747) and Node reverted it in 24.18.0 (nodejs/node#63834). Node 24.16.0 is pinned in 17 places including the Dockerfiles, so bump Playwright instead — it is a dev dependency, and `^1.56.1` already permitted 1.62.1; only the lockfile pinned it. Staying at or above 1.62.1 also avoids the tsconfig-resolution regressions in 1.62.0. Caching alone could not have fixed this: a cold cache still hangs, and what would have been cached is the corrupt binary. So the ffmpeg download is now restored from cache keyed on the resolved playwright-core version, the install is skipped outright on a hit, and the cache is only saved once the binary is verified to actually execute — a partial extraction can never be promoted into a cache that every later job restores. Per job: 90s to ~0s on a hit, ~2s on a miss.
* 🕊️ feat: yield to subagent completion wakeups * ⚡ fix: bound wakeup status guidance
* feat: add bounded subagent orchestration snapshots * fix: harden orchestration snapshot selection * fix: close snapshot settlement race * fix: preserve retry lease uncertainty * fix: classify bounded sibling leases * fix: classify captured terminal leases * fix: enforce snapshot byte budget * fix: retain terminal lease evidence
* test: prove cross-replica subagent delivery * test: harden redis integration timing * test: sort cross-replica integration imports
* 🛟 fix: Report Skill Sync Files Whose Paths Cannot Be Mirrored
`discoverSkills` dropped any file whose path failed `isSafeRelativePath`
with no warning, no count, and no record. The skill published, reported
`succeeded`, and was missing files — invisible from the mirrored copy.
Real case: NVIDIA/skills has two such files (spaces in the filename), so
that repository syncs "cleanly" while silently losing them.
Matches what zip import already does — record the file, keep the skill —
and mirrors the existing `skippedSkills` shape into `skippedFiles` /
`skippedFileCount` on the sync status. Dropped files now make a run
`partial`, since a run reporting `succeeded` while dropping content is
the bug.
Only dropped *skills* can still make a run `failed`: a source that
published everything it found is a real mirror even if a file inside one
skill could not come along.
* 🔧 fix: Charge Dropped Files to the Skill That Published Them
Codex review: the up-front accounting counted a skill's unsupported files
whether or not that skill went on to publish. Two consequences — the status
described a skipped skill as published-but-incomplete, and enough failed
skills could consume the 20-entry sample and crowd out drops from skills
that actually published, which is the case the record exists for.
Now recorded at the two points a skill is counted as synced, matching what
`ISkillSyncSkippedFile` already documented ("the skill itself is live").
Also replaces `Array.prototype.at` in the new tests: it is outside this
package's lib target, so `tsc` rejected it even though jest ran it fine.
* feat: add optional collapse for long user messages Add a Chat > Messages preference (off by default) that clamps long user messages to a preview height with a gradient fade and a Show more toggle, so pasted text or code cannot dominate the thread. The clamp is visual only: overflow-hidden keeps the full text in the DOM, so it stays readable by assistive tech, copyable, and findable by in-page search. Renders children untouched while the preference is off, keeping the DOM identical to before. * fix: address review findings on the long-message clamp - Apply the clamp only when content actually overflows, so sub-tolerance content is never hidden without a toggle - Measure the inner unclamped wrapper so growth such as a font size change or late media layout re-trips the toggle while collapsed - Reveal the message when focus reaches clipped content, so links and code actions stay reachable without focusing hidden elements - Reset the reveal when the preference turns off, so re-enabling always starts from the collapsed preview * fix: refine clamp reveal, use Button primitive, cover steer parts - Reveal on focus only when the focused control is actually clipped, so tabbing into a visible link no longer expands the message - Measure in a layout effect so the first paint carries the clamp - Render the toggle through the shared Button primitive (link variant) instead of feature-local button styling - Persisted steering messages now collapse under the same preference; search-result previews stay unclamped by design * fix: reveal focused controls clipped by any amount at the boundary The overflow tolerance exists to absorb trailing markdown margins when deciding whether the message overflows; the focus check compares the focused control directly against the clamp boundary instead.
* 🗂️ feat: Scope Scheduled Chats to Chat Projects
Adds an optional chat-project destination to a schedule, plus the operator
config to require one — or to pin every scheduled run to a specific project.
Feature:
- `chatProjectId` on the schedule row, accepted on create/update, projected on
the wire, and carried into the run's conversation through the durable trigger
envelope's `run` context.
- `interface.schedules.requireProject` refuses schedules that are not filed
under a project; `interface.schedules.projectId` pins every run to one
project and implies the requirement.
- Dialog gains a project picker (required when configured, a read-only row when
pinned); the card shows the destination and the new disabled reasons.
Invariants:
- ONE resolver (`resolveScheduleProjectId`) decides the destination for the
write handler, the fire path, and the wire projection alike, and an operator
pin OUTRANKS the stored id in all three. Tightening the config therefore
redirects — or stops — existing schedules instead of grandfathering where
their runs land. A pin implies `requireProject` for the same reason: without
it, a row created before the pin would keep firing with no project at all.
- Create/fire precheck symmetry, mirroring `resolveAgentFireAccess`: a write
this handler accepts is one the next fire also accepts. Any edit leaving a
schedule ENABLED re-validates its EFFECTIVE (possibly stored) project, like
the existing stored-agent and cadence-floor rechecks. A DISABLING edit skips
the requirement, or a schedule auto-disabled for `project_required` could
never be turned off.
- Fire-time enforcement auto-disables rather than filing runs loose, matching
agent_deleted: new `project_required` (requirement raised after creation) and
`project_deleted` (gone, or pinned to a project this owner does not have)
reasons, both refused BEFORE a billed generation is dispatched, and both
advancing so a schedule can never wedge on the occurrence.
- `computeCreateDigest` appends the field only when present, so a payload
without a project digests byte-identically to one from before this change —
a create retried across the upgrade still matches its own row instead of
reading as key reuse.
- Project reads are scoped to the owner, so ownership and existence are the
same lookup; a read ERROR propagates instead of failing closed, so a Mongo
blip retries the fire rather than auto-disabling the schedule.
The trigger idempotency key hashes principal/event/target and never
`envelope.run`, so the added run field cannot destabilize delivery identity.
* 🗜️ fix: Keep the Schedule Dialog Inside Its Height Budget
The project picker landed as a new ROW in the schedule dialog, which broke the
e2e edit spec: `md:overflow-visible` turns off the template's scrolling from
`md` up, so the dialog's content must fit the viewport. The extra row pushed the
footer's Save button below a 720x1280 window, where Playwright reported a
visible, enabled button it could never click — 226 scroll-into-view retries and
a 2-minute timeout, on all three attempts.
Measured against `dev` at 1280x720 (Save button's bottom edge, viewport 720):
dev 688 (32px slack)
project row ~790 (off-screen, CI failure)
3-column row 704 (16px slack — half the budget spent)
this commit 686 (34px slack, 2px better than dev)
The identity row is now three columns — name, agent, project — and its caption
moved out of the agent cell to sit full width beneath the row: at a third of the
dialog that sentence wraps an extra line, and the row is the tallest thing
competing for the budget. The caption is grouped with the row rather than left
to the form's own 4-unit rhythm, which spent more height on the gap than the
caption occupies.
The e2e spec now asserts the button is in the viewport before clicking it, so
the next field that overflows this dialog says so in one line instead of a
two-minute timeout on a visible element. FOLLOWUPS.md records what the planned
dialog controls (multi-day weekly, timezone, attachments) need first: give
`ControlCombobox` the `portalElement` prop `Dropdown` already has, portal the
popovers into the dialog content, and let the form scroll again.
* 🧹 fix: Address Codex Review on Scheduled Chat Project Scope
Four P2 findings, all real.
Unreachable clearing path (ScheduleDialog). The picker only held live projects, so
`com_ui_schedule_project_none` was a PLACEHOLDER — nothing selectable. Once a
schedule had a project the owner could never take it away, leaving the server's
`chatProjectId: null` path reachable only by API. The picker now carries a real
"No project" option whenever a project is optional, and omits it when one is
required, where there is nothing valid to select.
Placeholder shown for a real project (ScheduleDialog). A stored or pinned project
outside the first loaded page had no name in the paged map, and the combobox
renders its placeholder for an empty display value — telling the owner a scoped
schedule had no project. That one project is now read by id, with the raw id as a
last resort: a poor label, but an honest one.
Project policy skipped at the resume boundary (service.ts). `claimScheduleResume`
re-applied the schedules gate, the revision fence, the kill switch and
SCHEDULES:USE, but not the project policy this PR added. Approving a paused run
whose project was deleted — or whose owner now sits under a requirement or a pin
it no longer satisfies — billed a continuation the very next scheduled fire would
refuse and auto-disable the schedule for. The effective-project resolution now
runs there too, refused before the lease and the capacity slot so a policy refusal
costs nothing and leaves no state to unwind. NOTE: agent access and balance are
still not rechecked on resume; that gap predates this PR and is left alone.
Per-card project derivation (ScheduleCard). Every card ran the projects hook and
rebuilt the full option array, name map, and one icon element per project, to use
a single name — O(schedules x projects) per render and per project-list refresh.
The hook is split: `useChatProjectNames` (map only, for the panel, which resolves
every card's name once and passes it down) and `useChatProjectPicker` (options and
pagination, for the dialog's one combobox). The panel skips the query entirely
until some schedule actually has a scope.
Tests: four at the resume boundary (verified failing without the gate) and four in
the dialog spec. The picker selection in one existing test now goes through the
search field — the popover's VIRTUALIZED renderer sizes its window from a scroll
height jsdom always reports as 0, so with three options it materialized only two.
Full schedules e2e re-run green against the rebuilt client.
* 🎯 fix: Settle Project-Policy Refusals and Keep Create Retries Idempotent
Second Codex round, four P2s. Three were consequences of the resume gate added in
the previous commit, which was half-built: it admitted where it should not and
stranded the run where it refused.
Project policy moves from `claimScheduleResume` into `isScheduleLive`'s `policy`
branch. Both entry points consult that branch FIRST, and both already route its
refusal through abort-and-settle — so a policy stop now settles the occurrence
instead of answering a bare 409 while the job stays `requires_action`, the card
keeps reading "Needs approval", and every retry repeats the same 409 until expiry.
No change to resume.js: the existing branch does the work.
The rule is deliberately NARROW. It refuses only where no valid destination is
left — the requirement is on with nothing satisfying it, or the schedule's own
project is gone (which also unset it on the conversation). It does NOT refuse
because an operator's pin moved: the paused conversation cannot be rebound
(`chatProjectId` is excluded from the resume context and the continuation reuses
the same conversationId), so refusing would strand a pending approval over a
destination it can never reach, for a pin that governs only where the NEXT run
lands — which the fire path already redirects.
Create retries are idempotent again. Project policy had been applied BEFORE the
`clientRequestId` replay lookup, so a raised requirement, a deleted project, or a
moved pin could answer 400 for a create that already committed — pushing the
client to rotate its key and create a DUPLICATE schedule, the exact failure the
key exists to prevent. Policy now applies only to a genuinely new insert, and the
digest is computed from the CLIENT's payload rather than the resolved destination,
so today's policy can no longer re-digest a genuine retry into a mismatch.
An explicit `chatProjectId: null` under a pin is refused rather than silently
resolved to the pin. The payload contract defines `null` as clearing the scope;
answering 201 while filing under the pin reported success for the opposite of what
was asked. Only an OMITTED field takes the pin silently.
Tests: five on the policy branch (both refusals verified failing without it, plus
guards that a moved pin and a live project still admit) and two on the handlers
(the pinned explicit clear, and a committed create recovered by retry after the
policy tightened — verified answering 400 without the reordering).
* 🧭 fix: Converge the Stored Project on the Destination a Fire Resolved
Third Codex round, four P2s.
The root confusion behind the resume findings: an operator pin outranks the stored
id at fire time, `fireSchedule` sends the pin in the trigger envelope, and the row
keeps its old value. The row therefore LIED about where that occurrence's
conversation went, and every later re-validation — the resume boundary above all —
checked a project the conversation was never filed under. A schedule storing A,
pinned to B, with B later deleted and A still live, was admitted for resume into a
conversation that had just been unscoped.
Fixed at the source rather than at each reader: a fire that resolves a destination
different from the stored one writes it back, claim-token fenced like every other
worker-side write and deliberately WITHOUT a configRevision bump — this is the
server reconciling itself to policy, not an owner edit, and a bump would fence an
in-flight occurrence off its own run. Written only AFTER the destination validates,
so an unusable pin never lands in the row, and best-effort: the envelope already
carries the right destination, so a failed write costs accuracy on a later recheck,
never the run itself. The wire projection already reported the pin, so this also
stops the row and the UI disagreeing.
An explicit `chatProjectId: null` under a pin is now refused on the DISABLING edit
path too. The pin check and the requirement are independent rules, and folding them
together let `{enabled: false, chatProjectId: null}` skip the pin check entirely,
unset the row, and answer with a wire projection still naming the pin. Only the
requirement is waived for a disabling edit.
The dialog no longer requires a project for an edit that leaves a schedule DISABLED.
The server waives the requirement there precisely so a row auto-disabled for
`project_required` can still be renamed or tidied up; requiring it in the form made
that unreachable, and an owner with no projects could not edit the stopped schedule
at all.
FOLLOWUPS.md records the two residual gaps with their exact triggers: the sub-second
deletion race inside the resume claim window (which needs the effective project
persisted per OCCURRENCE plus a distinct policy conflict routed through
abort-and-settle), and the fact that convergence happens only when a schedule fires.
Tests: four on convergence (pin written, no write when unchanged, no write for a
destination that failed validation, fire survives a failed write), two on the
disabling-edit pin rules, two in the dialog. Schedules e2e re-run green.
* 🔑 fix: Keep an Explicit Project Clear Out of an Omitted Field's Digest
`computeCreateDigest` appended `chatProjectId` on `!= null`, so an OMITTED field and
an explicit `null` produced the same digest. Because the replay lookup and
`matchesCreateIntent` deliberately run before project policy, a request could reuse a
pinned create's `clientRequestId` while explicitly sending `chatProjectId: null` and
receive 201 describing the pinned row — success reported for the opposite of what it
asked, and the pinned-clear refusal the normal create path applies never reached.
Now `!== undefined`: an omitted field still digests byte-identically to a payload from
before project scope existed, so a create in flight across the upgrade still matches
its own row, while an explicit clear is a distinct intent and digests differently. A
pre-scope client never sent the field at all, so nothing legacy can carry an explicit
null.
* 📍 fix: Validate a Paused Run Against the Project Its Own Occurrence Used
The schedule-wide convergence from the previous commit was not enough, and the
reason is the single-active run index: it covers `status: 'started'` only, so a
PAUSED run does not block the next occurrence. While run 1 sat paused in project A,
a pin move plus one later fire rewrote the schedule row to B — and the resume
policy then validated B while run 1's conversation was still filed under A. Delete
A and the continuation was admitted into a conversation that had just been unscoped.
That window lasts as long as the pause, not the sub-second race the previous commit
documented.
The reservation now records the destination THIS occurrence used, and
`isScheduleLive` validates that record when given the occurrence's `scheduledFor` —
which `resume.js` already reads two lines above the call. No new conflict type and
no settlement-path surgery: the refusal rides the abort-and-settle branch that check
already has.
An ABSENT record falls back to the schedule-level resolution rather than reading as
unscoped. A pre-scope occurrence, or one whose row is gone, must never be treated as
evidence to stop a run — the fallback keeps legacy paused runs behaving exactly as
they do today.
Schedule-wide convergence stays: it keeps the row honest for the UI and for every
check that has no occurrence in hand.
FOLLOWUPS.md now describes the one remaining gap accurately — a deletion inside the
claim window, which needs a distinct policy conflict routed through abort-and-settle
rather than the bare 409 an `inactive` conflict produces.
Tests: three on occurrence-vs-row precedence (the decisive one verified failing
without the lookup), two on what the reservation records, and the resume controller
spec now pins `scheduledFor` in the policy call. Schedules e2e re-run green.
* 🏷️ fix: Tell a Deliberately Unscoped Occurrence From an Unrecorded One
The occurrence fallback added in the previous commit conflated two different
absences. A post-upgrade run that deliberately went unscoped omitted the field
exactly like a row written before the field existed, so both took the fallback —
and a paused unscoped run was then validated against the schedule's CURRENT
project. Under a requirement or a pin added while it sat paused, that admitted a
billed continuation into a conversation satisfying no present policy.
The reservation now ALWAYS records its decision, `null` for unscoped, and the read
reports `recorded` from key PRESENCE rather than truthiness. Only an unknown record
— a pre-scope row, or no row at all — falls back to the schedule-level resolution;
a recorded null is the genuinely unscoped occurrence it says it is, and is refused
once a project becomes required.
The distinction rests entirely on a stored `null` surviving as a present key while a
never-written field stays absent, so that is asserted against real Mongo rather than
assumed: if it ever stopped holding, unscoped runs would silently start being
validated against the schedule's current project again.
Tests: two against mongodb-memory-server (recorded null vs never-written vs missing
row), plus refusal of a recorded-unscoped occurrence under a new requirement and the
preserved fallback for a pre-scope one. Schedules e2e green.
* 🔁 fix: Validate an Initial Scheduled Start Against Its Own Occurrence
The initial-start policy check in `request.js` called `isScheduleLive(..., { policy:
true })` without `scheduledFor`, so it fell back to the schedule-level resolution
even though the run row already carries the occurrence's recorded scope. An
occurrence reserved unscoped, with a pin introduced while its loopback request sat
queued, was therefore admitted against the new pin — producing a billed unscoped
conversation under a requirement it does not satisfy, from an envelope already built
without a project.
`scheduledFor` was already in scope there. Passing it makes the initial start and the
resume validate the same way: against the destination the occurrence itself recorded.
* feat: add parent-scoped subagent thread reads * fix: tighten child thread read bounds * perf: project child activity messages * test: update child activity route fixtures * test: satisfy response mock types * fix: bound child activity reads at storage * style: sort child activity imports * fix: bound child activity storage reads
* feat: add parent-scoped subagent thread reads * fix: tighten child thread read bounds * perf: project child activity messages * test: update child activity route fixtures * test: satisfy response mock types * fix: bound child activity reads at storage * style: sort child activity imports * fix: bound child activity storage reads * feat: show child activity in a parent-owned panel * fix: preserve side panel identity * fix: refresh child activity safely * fix: follow the selected child task * test: update child panel fixtures
…ursor (#15068) * fix: upgrade redis dependencies and code to avoid elasticache bigint bug * fix: preserve tls uri behavior with the node-redis v5 changes * fix: satisfy node-redis v5 socket typings and clear lint in touched specs The TLS spec passed `socket: { ca }` without `tls: true`, which node-redis v5 accepts at runtime (the rediss:// scheme sets the flag) but its typings reject, failing the type check. Assert the resolved socket options instead, which covers scheme inference in both directions rather than only that the constructor does not throw. The benchmark spec carried two lint warnings that predate this branch and only surface because CI lints changed files with --max-warnings=0: an unused cache binding and a test with no assertions. Drop the binding and assert the SCAN actually yielded keys, which is the behavior the page flattening changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sYJcABr6NEFmVvPxsWhfy --------- Co-authored-by: Arnau Berenguer Jiménez <arnau.berenguer@vista.com> Co-authored-by: NoOPeEKS <arnauapps@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
* feat: add interface option to hide response feedback buttons Adds `interface.feedback` to librechat.yaml. When set to false, the thumbs up/thumbs down buttons are removed from the message action row and the feedback endpoint rejects writes with 403, so deployments that do not consume the data can stop collecting it. Defaults to true. * refactor: hoist the feedback gate out of the message row and into typed middleware Reading startup config inside HoverButtons put a query observer and two Recoil subscriptions on every message row, and rows never unmount, so the cost grew with the conversation. Resolve the flag once per chat in useChatHelpers and carry it on TMessageChatContext; useMessageActions withholds handleFeedback when it is off, which the action row already treats as "no feedback controls". The flag now stays false until the config resolves, so a disabled deployment never flashes controls whose writes are rejected. Move the server-side policy into requireFeedbackEnabled under packages/api so the route keeps no policy of its own. * test: stub the feedback gate in specs that replace the api package The messages router now imports requireFeedbackEnabled, and express rejects an undefined handler at require time, so every spec that mocks @librechat/api wholesale has to carry the export.
* ⚡ perf: Swap the Transcript With the URL on Conversation Switch
Switching conversations left the PREVIOUS transcript painted under the new
URL. Two things on the critical path caused it, both fixed here.
`RouterProvider` commits location updates inside `React.startTransition` by
default in react-router v7, and a transition keeps the outgoing tree on
screen until the incoming one has fully rendered — so every millisecond the
next thread took to render was time spent looking at the previous one, and
React yields during that render, stretching it well past its CPU cost.
Nothing here reads route data through router loaders, so the transition
bought no pending UI; conversation state also still lives in Recoil, whose
transition-safe reads are gated behind `_TRANSITION_SUPPORT_UNSTABLE` hooks
this app does not use. `useTransitions={false}` puts the route change back
in the click's own task.
`navigateToConvo` also awaited `GET /api/convos/:id` before calling
`navigate()`, so the route did not change until a full server round trip
completed. The clicked row already carries its conversation, so the route
and conversation state now change together and the refetch reconciles
afterwards. The row is a list projection, so any previously fetched full
record underlays it — prompt prefix, sampling params and files survive the
switch, and a send during the reconcile window still carries the real
settings.
Measured on the built client with a 250ms conversation-fetch latency,
switching between two 30-turn conversations:
before cold click→url 527ms click→paint 931ms 14 stale frames (297ms)
warm click→url 474ms click→paint 838ms 12 stale frames (277ms)
after cold click→url ~190ms click→paint ~450ms 0 stale frames
warm click→url ~280ms click→paint ~280ms 0 stale frames
The warm switch now paints the new transcript in the same commit as the URL.
The warm-cache message loading this depends on is untouched.
Adds `e2e/benchmarks-navigation`, a react-scan benchmark that guards the
result: an in-page sampler records the route and the mounted conversation
once per animation frame, so a frame pairing the next URL with the previous
transcript is caught directly. The react-scan harness the reasoning
benchmark had inlined moves to `e2e/perf/scan.ts` and is now shared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qZJDNkyH5rgCz6KcLjseq
* 🎯 fix: Resolve Sidebar Rows by Their Accessible Button in the Nav Benchmark
The a11y pass on the sidebar moved the conversation row's `role="button"`
and `aria-label` off the `convo-item` container and onto a real `<button>`
that `ConvoLink` renders inside it. The benchmark's click helper required a
single node carrying both the testid and the label, so after merging dev it
found nothing and threw.
Match on whichever node inside a row carries the label and let the click
bubble to the container's handler, which still owns the navigation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qZJDNkyH5rgCz6KcLjseq
* 🛡️ fix: Close Three Navigation Races Found in Review
Codex review of the optimistic-navigation path found three real defects.
Superseded reconciliations were written unconditionally. Selecting B then C
before both records settled let B's response land last and restore B into
conversation state while the route and transcript showed C — and sends read
from that state, so a user could submit into a conversation they were no
longer looking at. Navigations now claim a shared token before any await and
late responses are discarded. The token is module state rather than a ref
because every sidebar row mounts its own hook instance, so a ref cannot see
that a click on a different row superseded this one.
The first visit to a conversation installed the sidebar row as active state.
That row is a projection without prompt prefix, sampling params, tools or
files, so the composer became usable with settings that silently fell back to
defaults. Only a conversation whose full record is already cached now takes
the instant path; the first visit keeps the previous behavior and moves the
route once the record is in hand. Every later switch to it is instant, which
is the case this PR set out to fix.
A failed record fetch removed the target's message cache even though, after
optimistic navigation, that query is already mounted — a transient error
could cancel an in-flight history fetch, or discard one that had succeeded,
with no route change left to remount it. That removal is now limited to a
conversation confirmed gone, and the first-visit path still clears before the
route moves, where a fresh mount follows.
The benchmark's round-trip assertion was also unfalsifiable: nothing delayed
the record request, so an implementation that awaits it still answered inside
the threshold. It now holds that request open and asserts the warm switch
completes while it is unresolved, which no wall-clock bound can fake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qZJDNkyH5rgCz6KcLjseq
* 🧭 fix: Tie Pending Navigation Work to the Route, Not a Token
Codex found that the navigation token only tracked calls made through this
hook. Every other way out of a conversation — `useNewConvo`, a link, a
redirect, the back button — moves the route without touching it, so a record
still in flight for the conversation being left passed the guard. On the
cached path that overwrote the new route's conversation state; on the
first-visit path it was worse, calling `navigate()` and pulling the user back
into a chat they had already left.
The token was the wrong question. What makes pending work still wanted is not
"was this the last conversation clicked" but "is the user still where they
were when it started" — and only the browser's own location sees every way
that can change. Each async step now captures the route before its request
and re-reads it before writing, which subsumes the superseded-click case the
token was added for and removes the module state entirely.
Reading `window.location` directly rather than `useLocation` keeps this free
of subscriptions: every sidebar row mounts this hook, so subscribing would
re-render all of them on every navigation — the cost this hook exists to
avoid. Comparing pathname against pathname also makes the basename cancel.
The tests move from `MemoryRouter` to a real history, since the mechanism is
now the browser location itself, and cover both bypass paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qZJDNkyH5rgCz6KcLjseq
* 🔢 fix: Keep the Last Click Authoritative Across First-Visit Navigations
Codex found that the route guard cannot separate two first-visit clicks from
each other. That path deliberately leaves the route where it is until the
record arrives, so clicking two uncached conversations in quick succession
has both requests capture the same pathname — whichever the network answered
first then navigated, and the later click was discarded. Response order
decided where the user landed instead of click order.
Restores a generation counter alongside the route check. Claiming the last
PR's removal of the token as a subsumption was wrong: the two guards answer
different questions and neither covers the other. The generation says "a
newer intent replaced this one"; the route says "the user left by some means
this hook never saw". Both are needed, and both are cheap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qZJDNkyH5rgCz6KcLjseq
* 🧹 refactor: Stop Writing Server Snapshots Into User-Owned Conversation State
Four review findings on this branch were all the same defect: navigation
started a background fetch and wrote its result into the conversation atom.
That atom is user-editable — model, endpoint, prompt prefix, sampling params
— and the target chat is interactive from the moment the route changes, so a
late write races the user and every other writer. Each round added another
predicate to the write ("is this still the last click?", "is the user still on
this route?"), and each predicate left one more writer uncovered; the last one
is a setting picked on the same route by the same navigation, which no
ordering or route guard can see.
Remove the write instead of guarding it. The warm path refreshes the React
Query cache and stops there, so the optimistic merge that lands with the route
is the navigation's last word. The refreshed record is consumed by the next
switch to that conversation, which is where a cached record is read anyway.
This also dissolves the queued-focus finding: `applyConversation` (and its
`requestChatFocus`) is now reachable only from paths that navigate, so a focus
intent can no longer outlive the navigation that requested it.
Scope the synchronous route commit to conversation switches. `useTransitions`
on the provider disabled transitions for every route, including the lazily
loaded prompts, skills, insights and project screens, where yielding to input
during a large first render is worth more than an atomic swap. The opt-out
now travels per navigation as `chatNavigation` (`flushSync`), applied in
`useNavigateToConvo` and `useNewConvo`.
Tests: the four behavioural guards fail against an implementation that
restores the background write, including a new case where the user picks a
model while the refresh is in flight.
* 🎯 fix: Decide Route Commit Once, and Keep Refreshed Settings Refreshed
Reverts the per-navigation transition opt-out from the previous commit. Review
asked to scope `useTransitions={false}` to conversation switches, and I scoped
it by passing an option at the call sites I knew about — then immediately
missed one: `finalHandler` promotes `/c/new` to the server-assigned ID and
navigates without it, so the atom identifies the real conversation while the
route and message query still say `new`.
That is not a missed call site, it is the wrong shape. Fourteen call sites
across components, chat hooks and SSE handlers navigate into `/c/*`; an opt-out
carried by each one is a list that rots as call sites are added, and five of the
fourteen were covered. The property is route-shaped, so the decision goes back
to the one place that sees every navigation. Answering the original critique on
its merits: nothing in the app reads route data through router loaders or
renders pending UI from `useNavigation`, so the transition produces no
interstitial on any route — it only defers the commit, which on the chat route
is the bug this PR exists to fix.
Two conversation fixes alongside it:
Sidebar rows no longer reinstate settings the background refresh replaced. The
row projection carries `endpoint`, `model` and `spec`, and the warm path
spreads the row over the cached record — so a row from before an edit made on
another device would undo that edit on every switch until the list refetched.
The refresh now merges the record into the list cache, which is what made
"picked up on the next switch" true rather than merely intended.
Starting a new chat now supersedes a pending first visit. "New chat" from
`/c/new` lands on `/c/new`, so the pathname is unchanged and the record for a
conversation the user just abandoned would land and pull them into it. The
navigation counter is exported as `supersedeNavigation` and called from
`useNewConvo`. Deliberately not called from the stream recoveries in
`useEventHandlers`/`useChatFunctions`: those are the app reacting, not the user
changing their mind, and they should not cancel a conversation the user opened.
Intent is a closed set; navigation is not.
Both new tests fail against the implementation they guard.
* 🧷 fix: Keep the Record Refresh Off List State and Off Background Composers
Three fixes to the previous two commits, all the same underlying mistake in
different places: something that started earlier landing on top of something
the user did later.
The list-cache write added last commit merged the whole fetched record into
every sidebar and pinned row. That response is a snapshot from before the
target was interactive, and the list is where renaming, pinning and sharing
land — so a rename completing while the request was in flight was silently
undone. This is the same stale-snapshot-over-live-state mistake the refresh
had just stopped making against the conversation atom, reintroduced one layer
down. It now writes `endpoint`, `model` and `spec` only, which is what the
staleness it exists to fix is about, and which no list mutation touches.
The route comparison ignored the query string. `/c/new?projectId=A` is a
different conversation scope than `/c/new`, and the landing chip re-scopes a
draft by writing the atom and rewriting search params in place — never through
a conversation hook, so neither the pathname nor the recorded intent moved. A
pending first-visit record would then land on the draft the user had just
re-scoped. The comparison now includes `search`.
Superseding moved from `switchToConversation` into `newConversation`, guarded
by `keepComposerState`. That flag marks a call that re-renders a composer an
earlier call already opened — agent metadata arriving late, for instance. The
user asked for nothing there, so it must not cancel a conversation they clicked
while it was in flight. `switchToConversation` has no callers outside this
hook, so the move loses no coverage.
The first two are covered by tests that fail against the implementation they
guard. The third is verified by inspection: exercising it needs the whole
`useNewConvo` provider tree, which is disproportionate for a one-line guard.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Show the context breakdown on hover instead of click, shrink the gauge, open and close the popover with a scale-and-fade transition, ease the collapsible with decelerating open and accelerating close curves, render the Messages segment solid, and pair legend row hover with a dimmed meter via a new highlightId prop on SegmentedMeter.
* feat: add temporary chat empty state and active indicator Temporary Chat gave users a toggle but no page-level confirmation that they had entered the mode or what it changes. The only cue was the toggle's pressed state, which is easy to miss, and the toggle itself retires once the conversation starts, leaving an active temporary chat with no indication at all. The landing now swaps its identity block for a temporary-chat empty state: a dashed message icon, a "Temporary Chat" heading, and a line explaining that the chat stays out of history and is deleted automatically. It clears on its own once the first message is sent, since the landing unmounts at that point. useTemporaryChat gains isActive for the window where temporary mode is locked in for a conversation in progress. TemporaryChatIndicator renders exactly then, so the toggle and the read-only pill never overlap. It is shown at every breakpoint, collapsing to the icon alone below md while keeping its accessible name. The copy matches actual behavior: buildRetentionVisibilityFilter keeps isTemporary conversations out of the list query, and temporary chats are stamped with expiredAt from temporaryChatRetention. * fix: keep temporary conversations out of the sidebar and compose the status pill The empty state told users a temporary chat would not appear in their history, but the client seeded it into the conversation list caches anyway, so the chat sat in the sidebar for the rest of the session until a refetch or reload dropped it. The history query already excludes temporary conversations server-side, so the copy described the intended behavior while the UI contradicted it. Temporary mode lives on the submission rather than on the draft conversation, so the optimistic record never carried the flag and every consumer of that cache entry read a new temporary chat as an ordinary one. It is now stamped onto the optimistic conversation, only when true so the legacy expiredAt inference is untouched, and the sync handler gains the same isTemporary guard the title handler already had. upsertConvoInAllQueries refuses temporary conversations outright, which holds the invariant at one point rather than at each caller. The header indicator now composes the shared Chip primitive instead of hand-building a pill. Its theme size and shape tokens resolve to the same 2.25rem height, 0.75rem radius and 0.375rem gap the local classes hardcoded, so the appearance is unchanged while the indicator follows future theme work. It also carries role="status" so the mode change reaches assistive technology, which matters below md where the label is visually hidden and only the icon remains.
* feat: configurable SearXNG search options SearXNG queries were hardcoded to google,bing,duckduckgo with no way to change the engine list, the result language, or the request timeout. Most self-hosted instances get served CAPTCHAs by DuckDuckGo, so a third of every query silently returns nothing and operators have no lever to pull. Add a searxngSearchOptions block to the webSearch config that accepts engines (as a comma-separated string or a list), language, timeRange, and timeout, and thread it through to the search tool. Engines are normalized to the comma-separated form SearXNG expects, with blank entries dropped so a stray comma cannot produce an empty engines parameter. Refs #14117 * fix: normalize SearXNG engines on the runtime config path The engines transform lived only on the zod schema, but loadCustomConfig returns the raw YAML object rather than result.data, so nothing downstream ever saw the transformed value. A YAML list reached the SDK as an array and threw "options?.engines?.trim is not a function" when the search tool was built, taking web search down entirely for the exact block the example yaml documents. An untrimmed string reached SearXNG with spaces still in it. Extract the normalization into normalizeSearxngEngines and apply it in loadWebSearchConfig as well as the schema, so both the parsed and the raw path produce the same comma-separated value. Widen the loader's parameter to TWebSearchConfigInput, which models engines as the list or string an operator actually writes, and cover the raw path with tests that call the loader rather than the schema. * chore: drop unused RerankerTypes import in web config loader
* 🔭 fix: Attach to Runs This Pane Did Not Start A run started somewhere else — another tab, another device, a scheduled trigger — announces itself to this client only through the user-scoped active job list. Nothing consumed it for attachment: `useActiveJobs` feeds the sidebar's generating indicators and a `hasActiveJob` hint inside the messages query, and that is all. That left the status query as the only path to an attachment, and it closes for the rest of a conversation's mount the moment it has answered inactive once, because `processedConvoRef` is set on that answer. So a pane already sitting on a conversation when a run begins elsewhere never attaches, never refetches (the messages query disables refetch on focus, mount and reconnect), and shows history it cannot see has moved on — until a reload or a navigation remounts the query. Worse than the stale render: a send from that pane derives its parent from the stale tail and forks a sibling branch. The two send-time staleness guards in `useChatFunctions` do not fire, because nothing invalidated this pane's cache, so it looks fresh. Re-arm the status query when the viewed conversation appears in the active list. The announcement is consumed once per run rather than held open — a job stays listed for its whole lifetime, and re-opening on every poll would turn a five-second heartbeat into a five-second status read — and released when the run leaves the list so the next one re-arms in turn. * 🩺 fix: Make the External-Run Re-Arm Survive Warm Caches and Back-to-Back Runs Five gaps between the announcement and the attachment it was supposed to produce, none of which the happy-path test could see. The announcement could never arrive. `useActiveJobs` disables its interval while nothing is listed and `refetchOnWindowFocus: true` refetches only stale queries, so a run another client started inside the five-second `staleTime` window was invisible on return to the tab — the exact sequence this is for. Focus refetches unconditionally now. Re-arming could consume a stale answer. Toggling `enabled` only fetches when the cached data is stale, and `useStreamStatus` holds `staleTime: 1000`, so an inactive status answered moments earlier was replayed as "nothing running" and recorded as handled. The re-arm invalidates the status query rather than trusting the toggle. Attaching could graft onto a hole. An external client may have completed whole turns this pane never saw before starting the one now running; the resume submission and `finalHandler` both build on the local snapshot. And when the announced run turned out to be already terminal, nothing refreshed history at all — the messages query disables refetch on focus, mount and reconnect, so those turns simply stayed missing and a send from here still forked. The re-arm invalidates history too, which also re-gates `messagesLoaded` so the check waits for it. Consecutive runs could be missed. A latch released by observing the list empty never releases when a second run starts before the next poll, since the list reads the same throughout. Rate-limit to the list's own heartbeat instead, keyed on `dataUpdatedAt` — structural sharing keeps the payload reference stable across identical refetches, so only the fetch stamp moves. Wiring, found by these tests rather than by review: clearing a ref neither schedules a render nor re-runs an effect, so the arm is a state value the check depends on.
* fix: Support MCP Display Titles With Hyphens * fix: Preserve Legacy Regex Target Compatibility
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 : )