Commit graph

5129 commits

Author SHA1 Message Date
Danny Avila
1d9c2fc591
🪴 feat: Fork Completed Subagents Into Continuable Chats (#15133)
* feat: continue completed subagents as chats

* chore: sort continuation imports
2026-08-23 10:09:14 -04:00
Danny Avila
77cb72e50c
🧮 perf: Enable Agent Context Count Reuse (#15130)
* perf: enable agent context count reuse

* fix: declare token counter return type

* fix: initialize cached token counters

* style: sort token counter imports

* fix: Keep cached token counts exact
2026-08-23 03:05:02 -04:00
Danny Avila
e9a5b61f8c
⛩️ ci: Gate Backend Jest on Codegraph Selection (Stage 1) (#15132) 2026-08-23 03:04:27 -04:00
Danny Avila
8f7864ba2a
⏱️ test: Stabilize Meilisearch Retry Cleanup Assertion (#15129) 2026-08-23 02:37:57 -04:00
Danny Avila
8969ee4b18
🎚️ feat: Configure Agent Event Runtime in YAML (#15128) 2026-08-23 02:37:33 -04:00
Danny Avila
dd146ff74d
🧾 fix: Report Complete Agents API Usage (#15127)
* fix: report complete agents api usage

* fix: preserve invoked usage context

* test: cover absent usage context

* fix: type responses usage finalization

* fix: preserve reasoning usage aliases

* fix: declare reasoning usage alias
2026-08-23 02:37:09 -04:00
Danny Avila
719b04f389
🍃 ci: Cache MongoDB Memory-Server Binaries in Backend Test Jobs (#15131) 2026-08-23 02:36:15 -04:00
Danny Avila
2018c70040
🧫 test: Lock Subagent File Context Propagation (#15126) 2026-08-23 02:06:57 -04:00
Danny Avila
c411eb4cc6
📦 chore: bump @librechat/agents to v3.6.12 (#15125) 2026-08-23 01:59:59 -04:00
Danny Avila
c2aa688d73
🖥️ feat: Stream PTC Inner Tool Calls as a CLI-Style Trace (#15115)
* 🖥️ feat: Stream PTC Inner Tool Calls as a CLI-Style Trace

Programmatic tool calling runs a whole program inside the sandbox, and the
tool calls that program makes open no run step of their own. The card showed
one running spinner for the entire execution, with no sign of what the code
was doing.

Emit a new `on_ptc_tool_call` step event for each inner invocation — once on
dispatch, once on settle — and render them under the code as a terminal-style
trace: status glyph, tool identity, argument preview, duration, with a failure
message printed under the call that produced it.

The seam is the tool map the sandbox bridge resolves inner calls against.
`instrumentPtcToolMap` proxies `invoke` on each entry and leaves every other
property (name, schema, mcp) passing straight through, so nothing about
execution changes and emission failures can never fail a tool call.

Client state is a per-tool-call Recoil atom keyed like the sandbox-starting
and subagent atoms — live for the session, cleared on conversation switch so
a finished program's trace stays readable.

* 🩹 fix: Address Codex Review on the PTC Tool Trace

Five findings, all confirmed against the source before fixing.

Scope the trace atoms to a message occurrence. The hook already documents
that providers repeat a tool_call_id across turns and even within one
message, and `call_id` restarts at :0 for every outer call — so two programs
sharing `call_0` merged into one card. Key by (response message id, tool call
id) via `ptcTraceKey`, mirroring `subagentProgressKey`; the event's `runId`
already carries the message id and the card reads its own from MessageContext.

Prune unsettled rows on resume. Inner calls are not content parts, so the
resume snapshot cannot rebuild them, and `trackReplayEvent` only persists
OAuth events — a call that settled during a disconnect left a spinner that
never resolved. Settled rows are real history and stay.

Make the argument preview budget-aware. Iterate keys rather than entries so
the budget check can actually skip work, and clip against a bounded window so
a multi-megabyte value is never collapsed in full to build a 40-character
preview.

Catch the resumable emission promise. The synchronous try/catch around the
emitter cannot observe a rejected `emitChunk`, so a failing transport raised
an unhandled rejection per event instead of dropping telemetry.

Announce completion to assistive technology. The check glyph is decorative and
a fast call renders no duration, so a settled row previously announced no
outcome; each row now carries an sr-only status and the visible cell that
duplicated it is hidden.

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

* 🧹 fix: Repair CI Failures on the PTC Tool Trace

Two failures on the previous head, both mine.

`Tests: api (shard 2/3)` — 46 failures in `initialize.spec.js`, all
`TypeError: createPtcProgressEmitter is not a function`. The suite mocks the
callbacks module with an object literal, and wiring the new emitter into
`initialize.js` without adding it there left the factory undefined at call
time. Added it alongside `createAttachmentEmitter`, plus an assertion that it
receives the same generation fence as every other resumable emitter — a stale
epoch would leak one run's inner calls into the next.

`Static checks` — import-order drift in `PtcToolTrace.tsx` and `handlers.ts`,
repaired with `scripts/sort-imports.mts`. ESLint and Prettier both passed, so
only the dedicated check caught it.

`openai.js` and `responses.js` never take the emitter, so their specs were
unaffected; verified the initialize mock now covers every name the module
destructures.

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

* 🔐 fix: Address Second Codex Review on the PTC Tool Trace

Three of five findings actioned; two answered on the thread.

Respect tool-argument PII filtering (P1). Inner calls never reach
`filteredToolArgumentsResult` — the sandbox bridge invokes them directly — so
the trace was the one path putting their values on the wire in a deployment
that had configured `filters.toolArguments.pii`. When any of the name /
arguments / output fields are filtered, the emitter now omits both the
argument preview and the failure message, which routinely quotes the argument
that caused it. Name, status and duration still report.

Drop the light/dark-specific background (P1). `dark:bg-transparent` stepped
outside the semantic roles and would lose the intended separation under a
custom theme. The pane now sets no background at all and inherits the card's
surface, which resolves to the same color the override produced in both
default themes and stays correct when a theme reassigns its roles.

Bound the live trace (P2). A program looping over a large collection made
every event copy an ever-growing array and rendered a row per call. The trace
now keeps a rolling tail of 100 rows and counts what it evicted, surfaced as
"+N earlier calls" so the cap is never silent. A settle whose row is gone —
evicted, or pruned across a resume gap — no longer reappears out of order.

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

*  test: Keep PTC Trace Tests Aligned With Caller-Capability Filtering

Left out of the merge commit by a staging slip; without them
`handlers.spec.ts` fails on the merged tree.

`#15105` restricts the PTC tool map to tools whose `allowed_callers` admit
code execution, so the existing trace test's registry entry — which declared
none, defaulting to `direct` — was filtered out before the instrumentation
could see it. Declare the fixture `code_execution`.

Add a guard for the resolution itself: a `direct`-only tool must never appear
in the instrumented map. Tracing wraps the eligible map, and this fails if a
later change reorders that and lets the trace widen what the sandbox reaches.

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

* 🛡️ fix: Close Name Disclosure and Follow the PTC Trace Tail

Two findings from the third Codex pass on `17a9ec9`.

Redact filtered inner-tool names (P1). The previous gate suppressed argument
and failure previews but the event still carried `name` verbatim, so a
deployment whose `filters.toolArguments.pii.fields` includes `name` could see
a blocked identifier disclosed through the trace — the one path inner calls
take, since they never reach `filteredToolArgumentsResult`. Inner tool names
are now inspected once per PTC call with the same `extractToolArgumentContent`
+ `inspectContent` pair the executor uses; any that trip the policy are left
unwrapped, so they still execute and emit nothing. An un-inspectable name
fails closed.

Follow the trace tail (P2). The row list is a 200px scroller that never moved,
so once a program exceeded the viewport the card sat on the oldest calls while
live activity accumulated below the fold. Reuse `useFollowScroll` — the hook
the code and command panes already use — which pins to the tail while calls
are running and yields the moment the reader scrolls up. The host card threads
its disclosure state so a collapsed pane is never scrolled invisibly.

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

* 📌 fix: Pin the PTC Trace Through Its Final Settle

The fourth Codex pass on `4bf68e1`, one P2 finding.

`useFollowScroll` returned early whenever `active` was false, so the one
change it most needed to follow was the one it skipped. A failing inner call
settles by appending its error line in the same commit that clears the last
running row: the content grows and the stream ends together, and the pin that
would have revealed that line never fired. On an expanded, bottom-pinned pane
the failure — the row a reader most wants — stayed below the fold.

The falling edge of `active` now pins too, but only when the content changed
with it. Ending a stream on its own still leaves the pane where the reader
left it, which is what the existing contract promises and what the sibling
code and command panes rely on; a reader who has scrolled up is untouched
either way.

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

* 🔌 fix: Keep PTC Calls That Outlive a Reconnect

Fifth Codex pass on `085a83f`; one of its two findings.

Pruning rows across a resume gap deleted every `running` row, but a stream gap
is not proof the call ended. A call still executing across the reconnect
settles normally on the restored live stream — and `applyPtcToolCall` drops a
settle whose row is gone, by design, so an evicted row cannot reappear out of
order. The call therefore vanished from the trace despite having run, which is
worse than the spinner the pruning existed to prevent.

Rows are now marked `interrupted` instead of removed. A call whose settle was
genuinely lost in the gap reports that honestly rather than spinning forever,
and one that survives the gap settles onto the row it opened, reporting its
real outcome and duration. `interrupted` is a client-side conclusion, so it
widens the row status locally and leaves the wire contract alone.

Two cases added: the gap marks rather than drops, and a post-reconnect settle
lands on its marked row; plus a render case for the new outcome.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-23 01:18:14 -04:00
Danny Avila
caa938fec6
🎬 test: Cover Detached Subagent Activity Lifecycle (#15117)
* test: cover detached subagent activity lifecycle

* test: strengthen detached activity lifecycle gates

* test: tighten detached activity assertions
2026-08-23 01:16:25 -04:00
Danny Avila
1de88e7e91
📨 feat: Continue Bound Child Agents from Events (#15112)
* feat: add authenticated agent event ingress

* style: sort agent ingress imports

* fix: harden agent event ingress

* fix: bind event provenance to API keys

* fix: inspect event input with legacy PII filters

* fix: scope event status reads to source keys

* fix: bind event status reads to remote sources

* feat: add bound event-driven child turns

* fix: harden event-bound child continuations

* fix: satisfy event binding type contracts

* fix: close event actor lifecycle races

* fix: harden event actor dispatch continuity

* fix: fence event actor resume lifecycle

* fix: bind event actor state to lifecycle

* fix: preserve cascade write outcomes

* test: type cascade failure injection

* style: sort cascade test imports

* fix: harden event child lifecycle boundaries

* fix: make event cleanup retryable

* fix: annotate event retention clock

* fix: reconcile partial cascade metadata

* fix: recheck event binding expiry on resume

* fix: fence event actors by retention deadline

* fix: close event actor lifecycle races

* fix: harden event child lease acquisition

* fix: lazy-load event child lease adapter
2026-08-23 01:15:57 -04:00
Danny Avila
8f9fae0a6e
🛂 fix: Preserve Legacy Assistant Attribution (#15118) 2026-08-22 16:21:45 -04:00
Joseph Licata
fc7d56dffe
🪙 chore: Update GPT-5.6 Sol Token Cost Rates (#15114) 2026-08-22 11:02:29 -04:00
Danny Avila
89494d45fd
🚦 fix: Restrict Programmatic Tool Execution Maps (#15105)
* fix: restrict programmatic tool execution maps

* chore: bump `@librechat/agents` to v3.6.10

* fix: honor live programmatic caller projections

* style: sort caller capability imports

* test: expect caller projection loader argument

* chore: bump agents sdk to v3.6.11

* refactor: use SDK caller projection type

* style: sort agent handler imports
2026-08-22 11:02:09 -04:00
Danny Avila
d3e70159ca
📡 feat: Stream Detached Subagent Activity (#15111)
* feat: stream detached subagent activity

* fix: annotate activity stream limits

* fix: isolate subagent activity imports

* fix: harden detached subagent activity lifecycle

* test: cover synchronous activity transport failure

* test: include required subagent activity identity

* fix: identify and reconnect subagent activity events

* fix: bound subagent activity lifecycles

* fix: close subagent activity handoff races

* fix: bind and synchronize activity subscriptions

* fix: detect fresh activity attachment

* fix: complete activity synchronization handoff

* fix: bind activity sync and failure circuits

* fix: expose subscription-bound synchronization

* fix: fence activity reconnect publications

* test: make detached timeout settlement deterministic

* fix: fence Redis activity attachments

* fix: close failed activity streams

* perf: reuse fenced activity frontier

* style: sort subagent thread imports

* fix: preserve queued subagent activity

* test: type activity publication counter

* fix: disconnect subagent activity subscriber

* fix: close background activity lifecycle gaps

* fix: preserve streamed activity spacing

* fix: preserve bounded live subagent activity

* fix: merge durable subagent activity safely

* fix: model detached activity coverage

* fix: type detached activity inputs

* fix: order overlapping subagent activity

* chore: sort activity test imports

* fix: buffer subagent activity handoff gaps

* fix: flush activity after parent close

* fix: advance closed activity suffixes

* fix: preserve detached activity ordering

* fix: close detached activity delivery races

* fix: bound shared Redis subscriber readiness

* fix: expire shared Redis subscription readiness

* fix: clean up late Redis subscriptions

* fix: preserve late Redis subscription fallback
2026-08-22 09:45:27 -04:00
Danny Avila
3ebef4c84e
📨 feat: Add Authenticated Agent Event Ingress (#15110)
* feat: add authenticated agent event ingress

* style: sort agent ingress imports

* fix: harden agent event ingress

* fix: bind event provenance to API keys

* fix: inspect event input with legacy PII filters

* fix: scope event status reads to source keys

* fix: bind event status reads to remote sources
2026-08-22 01:09:04 -04:00
Danny Avila
f384e71f77
🧯 fix: Prevent Quote Popup Update Loop (#15113) 2026-08-22 01:08:44 -04:00
Danny Avila
67b7b441b2
🛂 feat: Filter Model-Bound Content by Source (#14425)
* feat: introduce optional content protection seam

* feat: enforce source-aware content filters

* feat: complete source-aware content enforcement

* test: activate skill file-text fail-close fixtures

* fix: harden source-aware content filters

* fix: harden model-bound content filtering

* fix: preserve legacy filters and generated files

* fix: inspect shared scalar metadata

* test: align mocks with current dev dependencies

* feat: add persisted content filter safeguards

* feat: complete source-aware content filter enforcement

* fix: move resume content preflight into TypeScript

* fix: close content inspection edge cases

* fix: harden content protection boundaries

* fix: complete content protection safeguards

* test: align persisted memory filter coverage

* fix: reconcile content protection with current dev

* fix: reconcile content protection with latest dev

* fix: close content protection review gaps

* fix: enforce source-aware provider boundaries

* fix: preserve legacy PII preflight semantics

* test: stabilize stored branch preflight fixture

* fix: defer agent writes until protected model admission

* perf: harden source-aware model-bound filtering

* fix: canonicalize provider lineage before validation

* fix: satisfy model-bound callback type checks

* perf: Bound content protection filtering work

* fix: Bound submission array traversal

* fix: Stabilize bounded content snapshots

* fix: Scope model-bound traversal overflows

* fix: Preserve scoped content inspection

* fix: Accumulate aggregate traversal scopes

* fix: centralize content policy boundaries

* test: align deferred tool policy context

* test: align controller policy mocks

* style: normalize content protection imports

* fix: close content policy review gaps

* fix: narrow active skill policy config

* fix: address content protection review boundaries

* fix: retain exact provenance overflow sentinel

* fix: preserve literal and scoped provenance updates

* fix: narrow persisted edit provenance

* fix: isolate exact overflow attribution

* fix: centralize stored prompt protection

* fix: fail closed on incomplete transcript evidence

* fix: align canonical transcript routing

* refactor: centralize content policy preflights

* fix: isolate upload policy error typing

* style: sort policy preflight imports

* refactor: centralize content policy boundaries
2026-08-21 22:43:32 -04:00
Danny Avila
10f95c0ce9
🐇 refactor: Decouple Meilisearch From Document Saves (#15109)
* 🐇 fix: Decouple Meilisearch from document saves

* 🧭 fix: Preserve detached indexing invariants

* 🧬 fix: Version detached Meilisearch writes
2026-08-21 22:26:44 -04:00
Danny Avila
08c9cc3d3d
🖼️ fix: Restore Shared Subagent Activity as a Read-Only View (#15108) 2026-08-21 20:58:14 -04:00
Marco Beretta
199de92c51
perf: Warm Feature Catalogs in the Background After First Paint (#15047)
* perf: warm feature catalogs in the background after first paint

Prompt groups and MCP server/tool queries no longer fire on the app
startup path. A catalog warmup store releases them after first paint
on browser idle, staggered with jitter so a fleet of clients does not
burst the API all at once. Panels opened before warmup activates
their catalog immediately and fall back to their existing loading
states. The prompts list endpoints now also run their independent
access lookups in parallel instead of in three serial rounds.

* perf: gate MCP icon observers and re-arm warmup across sessions

MCP icon/name observers mounted from rendered messages now wait for the
warmup gate like every other server-catalog consumer, so conversations
with MCP tool calls no longer pull the server list onto the first-render
path. The warmup schedule resets on logout so a second login in the same
tab warms on its own stagger instead of releasing every catalog at once.
Panel mount activations now require a visible sidebar, since a persisted
active panel stays mounted while hidden.

* perf: void stale warmup callbacks and gate the agent panel tools query

Reset now bumps a generation captured by every idle callback and its
stagger timer, so callbacks pending across a logout can no longer release
catalogs into the next session. The agent form's MCP tools query keeps
its own readiness gate so a hidden persisted panel cannot pull the tools
request ahead of its stagger once the server list resolves.

* perf: reset warmup on Root unmount and honor the insights route collapse

Root can unmount in the same render that flips authentication on logout,
so the warmup effect now resets from its cleanup as well as the
unauthenticated branch. Panel activations mirror UnifiedSidebar's
panelExpanded condition, treating the insights route as collapsed instead
of reading the raw sidebar atom.

* test: re-expand the approval tool card the saved message remounts

The helper opened the card once and then waited on its body. Saving the
response swaps the placeholder message id for the persisted one, which
rekeys every part in the turn: the card remounts collapsed, its body
unmounts, and the output assertion waits out its timeout against a
disclosure nothing is going to reopen. The redis transport lane pays a
round trip per stream event, so its finalization lands late enough to
catch the helper mid-assertion.

Wait for the closing model turn before expanding anything, then re-open
the group and the card on each attempt until the scoped output is on
screen.

* Update AgentPanelContext.tsx import order

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-21 20:33:29 -04:00
Danny Avila
749eed0d60
🪟 feat: Unify Subagent Activity Panel (#15106)
* feat: unify subagent activity panel

* fix: fence durable activity to selected task

* fix: preserve exact panel activity semantics

* fix: scope panel identity to parent turn

* fix: keep detached readiness status neutral

* fix: harden subagent activity invariants

* test: support backend TypeScript target

* fix: preserve subagent invocation identity

* fix: bound subagent activity correlation

* fix: drain exact-parent subagent updates
2026-08-21 19:51:11 -04:00
Danny Avila
21b7f78d56
🙋 feat: Collapse Settled Question Records by Default (#15107)
* 🙋 feat: Collapse Settled Question Records by Default

The durable `ask_user_question` record rendered as a permanently open
card. Answers are frequently long, multi-paragraph text, so a settled
Q&A buried the reply that followed it.

It now reads as one collapsed tool-call line — the same `ProgressText`
primitive `ToolCall`/`SkillCall` use — naming the question (or the
batch count, reusing the keys `ToolCallGroup` already had) and opening
on demand under the existing `autoExpandTools` preference. Only the
settled record collapses; the live pause and the interim progress card
are untouched.

The expanded panel was also hard to read. Authored text rendered
without `pre-wrap`, so a numbered or paragraphed answer collapsed into
one wall; the answer ran on from its inline label; and batch items sat
flush against their divider. Line breaks are now content, the answer
sits under its own label behind a rule, and dividers have air on both
sides.

`ProgressText`'s subtitle now truncates and absorbs the flex shrink, so
arbitrary authored text ellipsizes instead of pushing the line past the
message column — this also fixes long MCP server names on tool cards.

* 🩹 fix: Address Codex Round 1 on the Collapsed Question Record

- Settle the summary tense. A live, unanswered pause returns before the
  header, so every state reaching it is settled — an abandoned pause read
  "Asking" forever, and the collapse hid the "no answer" line that used to
  qualify it. Past tense unconditionally, matching `ToolCallGroup`.

- Move the rejection announcement out of the disclosure. `useExpandCollapse`
  marks the closed panel `inert`, so the failure explanation's `role="status"`
  could never reach the accessibility tree; it is now an sr-only status
  outside the panel, carrying both the label and the explanation.

- Count records, not repeated text, in the Bombadil observation. With
  Auto-expand tool details on, one settled record shows the question in both
  its summary line and its panel, so the old selector double-counted it and
  broke the `<= 1` singularity invariant.
2026-08-21 19:50:40 -04:00
Danny Avila
8ae94afa91
🪡 fix: Thread Parent Message ID Through MCP Request-Scoped Bodies (#15095)
* fix: Unify MCP request-scoped headers

* fix: address request-scoped MCP review findings

* test: preserve request scope on status errors

* fix: treat authorized on-demand MCP servers as ready

* refactor: separate MCP readiness from connection state

* fix: preserve on-demand MCP readiness labels

* test: satisfy OpenAI conversation ownership guard

* fix: keep MCP action predicates boolean

* fix: close deferred MCP request context gaps

* fix: preserve on-demand MCP configuration actions

* fix: fail closed on unavailable MCP parent context

* test: complete MCP connecting-state mocks

* fix: preserve missing MCP parent on continuations

* fix: align native MCP request identities

* fix: preserve edited MCP parent identity

* test: use scoped Agent initializer fixture

* test: expose MCP request body helper

* fix: preserve MCP turn identity across resume

* style: sort stream metadata imports

* fix: carry normalized MCP identity to execution
2026-08-21 16:33:01 -04:00
Dustin Healy
f02ce63d57
✂️ fix: Strip Redundant Server-Name Prefixes from MCP Tool Keys (#14732)
* ✂️ fix: Strip Redundant Server-Name Prefixes from MCP Tool Keys

MCP servers that prefix every tool with their own name produce model-facing keys that embed the server twice once the _mcp_<server> suffix is appended, pushing long tool names past provider 64-character function-name limits. Tool keys now drop a leading <normalizedServerName>_ prefix (case-insensitive, skipped when a sibling tool already owns the stripped name). The original upstream name is recorded as serverToolName on the cached definition and is always what tool calls send to the server, and runtime lookups also try the stripped spelling of persisted pre-strip keys so existing agents keep resolving.

* 🩹 fix: Keep Stripped MCP Tool Keys Provider-Safe and Collision-Free

Assistant writers submit catalog entries verbatim, so the internal serverToolName mapping is now removed from provider-facing definitions before they reach create/update payloads. Prefix stripping is collision-guarded over the resulting name set rather than raw siblings only, which also covers case-variant prefixed pairs under the case-insensitive match.

Assistant payload healing now rewrites a pre-strip persisted key to the stripped catalog key when that key actually exists in the loaded definitions, and legacy agent references keep their persisted spelling as the runtime instance name so per-tool options stay applied while the upstream call still uses the matched entry's raw name.

* 🧷 fix: Harden Stripped MCP Tool Keys Against Heal, Collision, and Cache Edges

The pre-strip heal now resolves the key boundary against both raw and normalized server spellings, mapping back to the raw name for the shadow and membership guards, so keys persisted after server-name normalization heal too. Collision detection iterates to a fixpoint so a fallback to a raw name cannot silently collide with another sibling's stripped result, and a stripped remainder equal to a synthetic marker (wildcard or server pin) is never produced.

MCP catalog cache slices are versioned so replicas that predate serverToolName never read stripped entries during a rolling deploy; stale slices expire on their own.

* 🔎 fix: Resolve Pre-Strip Keys in Event-Driven Definitions and Reinspect Persisted Catalogs

The event-driven definitions loader now tries the stripped spelling of a persisted key when the exact lookup misses, keeping the persisted name so it matches the runtime instance, which stops legacy agents from failing initialization with expected tools unavailable. The registry storage schema version is bumped so followers rebuild persisted toolFunctions instead of republishing pre-strip definitions into the versioned catalog namespace.

The assistants heal also fails closed when a normalized-suffix reference lands on a contested server-name slot, since rewriting persisted data must not bind an ambiguous reference to the tie-break winner.

* 🛰️ fix: Reserve the Synthetic OAuth Name and Heal User-Owned Server Keys

A stripped remainder equal to oauth would make the client stream handlers treat a real tool call as a synthetic authentication prompt, so it joins the reserved remainders alongside the wildcard and pin markers.

The assistants heal now audits the FULL accessible server set on every run instead of operator config names only, since assistants reference user-owned servers whose catalogs the definitions loader already resolves; an unavailable audit still skips healing entirely.

* 🧬 fix: Verify Upstream Identity for Legacy Keys and Reserve Sibling Raw Names

Stripped results now reserve every sibling's raw name even when that sibling itself strips, so a stripped key can never shadow another tool's pre-rollout persisted references within the same snapshot. Every legacy fallback (runtime lookup, event-driven definitions, assistants heal) accepts a stripped-spelling match only when the entry's recorded serverToolName proves the same upstream tool, so a stale key for a removed tool degrades to unavailable instead of calling a different sibling.

To keep that identity visible to the heal, assistant tool definitions retain serverToolName and the controllers sanitize entries through toProviderToolDefinition at the provider submission boundary instead. The agent editor migrates pre-strip persisted ids the same identity-verified way, with the upstream name exposed on the MCP tools payload.

* 🧭 fix: Heal Wildcard Tool Options and Reserve the OAuth Namespace

Wildcard-expanded catalogs rename stripped tools without any agent.tools entry to preserve the spelling, so buildToolClassification now aliases persisted pre-strip tool_options keys onto the current instance names in place, identity-gated on the definition's recorded upstream name and never overriding an explicit entry. Both loading modes flow through it: instances carry mcpServerToolName from createToolInstance and event-driven definitions thread serverToolName from the catalog.

stripServerNamePrefix also reserves the entire oauth namespace rather than the exact name, since the client stream handlers classify every oauth-prefixed key as a synthetic authentication call.

* 🛡️ fix: Derive the Full Reserved Namespace and Heal Approval Policies

The reservation guard now covers every namespace consumers classify by prefix: the wildcard and server-pin markers alongside oauth, plus the server-scoped mcp_ pluginKey namespace that pre-strip keys could never enter. Stripping also never produces a key whose isActionTool classification differs from the raw key's, since a server whose normalized name contains _action_ would otherwise see a real MCP tool routed down the OpenAPI action path past MCP authorization.

Admin toolApproval globs written against upstream tool naming keep applying: pattern lists are healed at run wiring with the current names of tools whose pre-strip spelling matches, list-level so deny, ask, and allow precedence is unchanged and a non-matching deny can no longer fail open. The MCP tools wire type also declares serverToolName end to end.

* 🪪 fix: Alias Both Key Spellings for Approval Policies and Hook Matchers

Identity aliases are now collected once at tool classification, in both directions: a stripped instance aliases its pre-strip spelling and a legacy-named instance aliases its current catalog spelling, with the current name recorded on legacy matches by the runtime lookup and the event-driven definitions loader alike. The aliases ride the agent config through both loading modes, so approval pattern healing applies to deny rules written against either spelling, closing the bypass where a rule targeting the current name missed an unedited agent's legacy instance.

Programmatic approval hook matchers get the same treatment: each hook is additionally registered under an anchored exact-name pattern for tools whose other spelling its regex matches, keeping the admin's matcher semantics intact while argument, user, and tenant specific deny or ask decisions keep executing for renamed tools.

* 🔁 fix: Alias Tool Options in Both Spelling Directions

Options aliasing now consumes the same bidirectional alias pairs as policy healing and hook matchers, so options the editor migrated to the current catalog spelling still reach a legacy-named instance retained by an unedited agent.tools entry. The previous serverToolName-only derivation skipped exactly that case since the legacy key equals the instance name there.

* 🤝 fix: Reserve the Agent Handoff Namespace Before Stripping

The client renders any lc_transfer_to_ prefixed call as an agent handoff and the background and intent passes exclude such names, so a stripped remainder inside that namespace would misclassify a real upstream tool. It joins the mcp_ pluginKey namespace as a bare-prefix reservation, which pre-strip keys could never enter.

*  fix: Reuse the Loader's Server Snapshot and Index the Editor Catalog

getAssistantToolDefinitions now returns the accessible-server snapshot from the same merged registry read that resolved the catalogs, and the heal consumes it instead of repeating the app-config and registry round trips on the assistant write path; without a snapshot the heal still fetches and fails closed as before.

The agent editor's id migration uses a memoized tool_id map, so the per-key form heal does constant-time lookups instead of scanning the catalog per option.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-21 14:30:29 -04:00
Yorgos K
9f8d71a3c5
🪢 fix: Preserve Response Identity and Branch During Resumable SSE Sync (#14788)
* fix(client): preserve resumable response identity

Fixes #14787

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

* test(client): align resumable sync regressions

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

* test(client): clarify resumable response ownership

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

* fix(client): preserve resumed regeneration ordering

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

* test(client): cover missing resumed response row

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

* fix(client): preserve resume identity on page reload

* fix(client): replace reassigned resume placeholder

* fix(client): preserve content during response id handoff

* fix(client): limit resume placeholder handoff

* fix(client): preserve resume display metadata

* fix(client): reconcile resume metadata in one pass

* fix(client): reconcile preliminary resume user

* fix(client): restore regenerated branch on early abort

* test(client): cover external regeneration resume

* fix(client): preserve regeneration history on errors

* fix(client): replace reused regeneration error ids

* fix(client): preserve exact-id regeneration rollback

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-21 14:25:03 -04:00
Oliver Faust
b399ad8370
📄 fix: Serve Stored Text for "Upload as Text" File Downloads (#14723)
* 📄 fix: Serve Stored Text for Text-Source File Downloads

"Upload as Text" attachments store extracted content in the DB with
source 'text'; OCR uploads persist the OCR strategy name (e.g.
'mistral_ocr') as a filepath placeholder since no backing file exists.
The download route resolved these records to the local strategy and
passed the placeholder to fs.createReadStream, which failed with
ENOENT — and the response was never ended after the stream error, so
the request hung until the client timed out.

Serve the stored text directly as a .txt download for text-source
files (re-fetched by _id, as getFiles excludes 'text' by default),
and end the response on stream errors: 500 without the download
headers before headers are sent, otherwise abort the truncated
response so clients detect the failure.

* fix: Preserve text-source preview semantics

* fix: Complete text-source download coverage

* fix: Tie text downloads to blob lifecycle

* fix: Isolate preview downloads and share text snapshots

* fix: Keep shared previews in share scope

* style: Sort text download imports

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-21 14:24:43 -04:00
Danny Avila
d602452c05
🪪 fix: Support MCP Server Titles With Hyphens (#15094)
* fix: Support MCP Display Titles With Hyphens

* fix: Preserve Legacy Regex Target Compatibility
2026-08-21 12:44:31 -04:00
Danny Avila
5a598a138f
🔭 fix: Attach to Runs This Pane Did Not Start (#15074)
* 🔭 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.
2026-08-21 12:14:12 -04:00
Marco Beretta
33e42e6d5d
🎛️ feat: Configurable SearXNG Search Options (#14987)
* 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
2026-08-21 11:36:23 -04:00
Marco Beretta
f5f462a1c6
🫥 feat: Add Temporary Chat Empty State and Active Indicator (#15086)
* 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.
2026-08-21 11:35:12 -04:00
Marco Beretta
6757c65a54
feat: Context Gauge Hover Reveal and Breakdown Motion Polish (#15038)
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.
2026-08-21 11:34:36 -04:00
Danny Avila
393742016e
🔀 perf: Swap the Transcript With the URL on Conversation Switch (#15054)
*  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>
2026-08-21 11:28:18 -04:00
Marco Beretta
29f6ec6eae
🙈 feat: Config Option to Hide Response Feedback Buttons (#15085)
* 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.
2026-08-21 11:16:44 -04:00
Danny Avila
dfa2cd5049
🧬 chore: Upgrade Redis Dependencies to Dodge the ElastiCache BigInt Cursor (#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>
2026-08-21 11:14:37 -04:00
Danny Avila
876a087558
🛰️ feat: Show Child Agent Activity in a Side Panel (#15075)
* 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
2026-08-21 11:10:41 -04:00
Danny Avila
634432b2ae
🪟 feat: Read Child Threads Through Their Parent (#15073)
* 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
2026-08-21 11:10:26 -04:00
Danny Avila
8c14f03432
🗂️ feat: Scope Scheduled Chats to Chat Projects (#15056)
* 🗂️ 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.
2026-08-21 11:09:04 -04:00
Marco Beretta
3b339219ba
📜 feat: Add Optional Collapse for Long User Messages (#15034)
* 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.
2026-08-21 03:38:01 -04:00
Danny Avila
757fbebc37
🛟 fix: Report Skill Sync Files Whose Paths Cannot Be Mirrored (#15067)
* 🛟 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.
2026-08-21 03:36:27 -04:00
Danny Avila
17a02ac804
🛰️ test: Prove Cross-Replica Subagent Delivery (#15064)
* test: prove cross-replica subagent delivery

* test: harden redis integration timing

* test: sort cross-replica integration imports
2026-08-21 03:36:11 -04:00
Danny Avila
6d09a6ccee
🧾 feat: Sibling Task Manifest for Resumed Parent Runs (#15063)
* 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
2026-08-21 03:35:46 -04:00
Danny Avila
a5cb041f47
🕊️ feat: Yield to Subagent Completion Wakeups (#15066)
* 🕊️ feat: yield to subagent completion wakeups

*  fix: bound wakeup status guidance
2026-08-21 01:45:42 -04:00
Danny Avila
061e4b02a3
🎞️ ci: Fix Playwright ffmpeg Install Hang and Cache the Download (#15065)
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.
2026-08-21 01:36:09 -04:00
Danny Avila
d6d6b04804
📱 fix: Recover the Stream After a Mobile Tab is Backgrounded (#15050)
* 📱 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.
2026-08-21 01:33:39 -04:00
Danny Avila
4c45d156af
🔌 refactor: Extract Git Repository Adapter From Skill Sync (#15052)
* 🔌 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.
2026-08-21 01:14:11 -04:00
Danny Avila
d0f9d5625e
🧵 fix: Close Child-Thread Read and Search-Cleanup Gaps (#15055)
* 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
2026-08-21 00:44:37 -04:00
Danny Avila
c7e355b219
🛑 fix: Confirm Scheduled Stops and Separate Terminal Scheduler Failure 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.
2026-08-20 18:33:51 -04:00
Danny Avila
9c9696de8b
🧵 fix: Hide Child Threads From Navigation (#15041)
* 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
2026-08-20 18:33:25 -04:00