* 🧠 fix: Restore Agent Memory Scope Control in Unified Builder
The Agent Builder redesign (#13952) surfaced memory as a Tools marketplace
item and stopped rendering SidePanel/Agents/Memory.tsx, orphaning the file and
removing the 'Keep memories separate for this agent' control shipped in #14084.
Only the render was lost: the locale keys, memory_scope on AgentForm, the
AgentPanel save path, and AgentSelect hydration all survived, which is why
setting memory_scope directly on the agent document still worked.
Restore it as a builtin item setting (the seam the new builder uses for
per-tool config): mark memory configurable so its row gets a cog, and render a
MemoryConfig branch in BuiltinSection mirroring ArtifactsConfig. Delete the
orphaned component so there is a single source of truth.
Resolves#14287
* 🧹 chore: Remove Orphaned com_agents_enable_memory i18n Key
The key labeled the enable checkbox in the deleted SidePanel/Agents/Memory.tsx.
The unified builder labels the catalog item via com_ui_memory, so it has no
remaining consumer and detect-unused-i18n-keys fails on it.
- Float the in-flight steer stack as a bottom-anchored overlay (`absolute
bottom-full`) so it no longer shrinks the message viewport and older messages
scroll behind it; a per-conversation Jotai height atom reserves an equal band
of bottom padding on the message scroll area so the newest message rests clear
- Collapse long steers to a ~128px preview with a bottom fade and a Show
more/Show less toggle, shown only once the content overflows the cap
- Sticky the per-steer options menu (`sticky top-2`) so it stays reachable while
scrolling through a tall, expanded steer
* 🧬 perf: Memoize Message Spine and Isolate Scroll-Button State
* ✅ test: Pin the message-row memo comparators against field drift
areMessageFieldsEqual and areMessageRowPropsEqual gate every message row's
re-render but had no direct tests. Add a completeness suite: a field-mutation
table asserts each compared field flips the comparator to false (a dropped
field fails its case), plus same-ref / equal-distinct-objects / nullish cases,
and the same shape for the row-props comparator including its delegation into
areMessageFieldsEqual.
* 🎛️ perf: Narrow Composer Subscriptions to Streaming State
* ✅ test: Cover Composer Subscription Refactor's Behavioral Changes
Add regression tests for the previously-uncovered changed behavior in the
composer-subscriptions refactor:
- useLatestMessageMeta: exact projected field set, null on empty cache, and
referential stability + no re-render across token-only cache writes.
- useGetLatestMessage: call-time tail read, stable callback identity with no
re-render on cache writes, Recoil-snapshot sibling-branch resolution, null
with no conversation.
- useSubmitMessage: reads the tail at call time and appends it to root when
missing (and does not when present or absent) — the reconcile branch the
prior test skipped via an early return.
- useHandleKeyUp: ArrowUp in an empty composer clicks the latest message's edit
control, with the null / missing-control / non-empty-composer guards.
- useAskAnswerMode (new spec): liveAsk is projected through the
findLiveAskUserQuestion select, null when empty/disabled.
* 🎨 style: Fix import order in useLatestMessage spec
* 🐛 fix: Dedup retried start-generation requests to prevent duplicate billing
A lost or reset start-generation response makes the client re-POST the
identical payload (up to 3x on network errors). The resumable-stream
controller had no idempotency: createJob unconditionally overwrote the
running job without aborting the prior one, so both requests ran full
LLM completions and both billed while the UI showed only one (#14339).
Add a stable per-submission clientRequestId (uuid, fresh per ask() so a
regenerate differs, reused across the start-generation retries) and an
atomic claim on the job store keyed by userId:clientRequestId. The first
request wins and generates; a retried POST loses the claim and receives
the original stream, which the client subscribes to and replays - no
second billed generation.
- IJobStore.claimIdempotencyKey/releaseIdempotencyKey (in-memory Map+TTL,
Redis single-key SET NX PX + GET Lua, cluster-safe)
- GenerationJobManager.claimGeneration/releaseGeneration (20m TTL)
- Controller claims before the concurrency check, dedups with a resumed
response, releases on start-failure/429
- clientRequestId threaded through TSubmission/TPayload/createPayload
* 🐛 fix: Harden start-generation dedup (Codex review)
Address three P2 findings on the idempotency path:
- Resume replay: a deduped retry now subscribes with resume=true so the
client replays prior content and any pending-action from the running
stream instead of only live events (cross-replica / HITL correctness).
startGeneration returns { streamId, resumed } and the response's
status:'resumed' drives the subscribe mode.
- Wait for the job record: a duplicate that loses the claim now waits
briefly for the winner to create the job before returning the stream
(a stream with no job 404s terminally). If the winner has not
materialized, return 503 SERVER_NOT_READY so the client retries via the
existing readiness path instead of attaching to a dead stream.
- Release only owned claims: track whether the request actually won the
claim; the 429 and init-error paths no longer release a claim owned by
another in-flight generation (fail-open path could erase it and
re-enable double billing).
Adds controller tests covering dedup, the 503 race fallback, win-then-
create, and claim-release ownership on 429 / fail-open.
* 🐛 fix: Don't trap deduped retries on missing job records (Codex review)
The previous round returned 503 SERVER_NOT_READY when a deduped retry's
job record was absent. But a missing job usually means the original
generation already completed and was cleaned up (cleanupOnComplete) — the
correct recovery is to return the stream and let the client's subscribe
404 handler refetch the persisted messages. The 503 instead trapped the
send in a readiness-retry loop until the client's window expired.
Keep the bounded wait (it still covers the job-about-to-be-created race)
but always return the resumed stream afterward; a gone/never-created job
recovers via the client's existing 404 path instead of being treated as
indefinitely starting. Updated the controller test accordingly.
* 🐛 fix: Gate deduped resume on claim age, not just job presence (Codex review)
Removing the 503 entirely (previous round) reintroduced the inverse race:
if the winning request stalls between claimGeneration and createJob, a
losing duplicate saw no job, returned status:'resumed' anyway, and the
client subscribed to a stream that did not exist yet — the 404 handler
tore the turn down while the winner went on to generate and bill with no
UI attached.
Distinguish the two missing-job cases by claim age (claimedAt now travels
on the claim value):
- fresh claim, no job yet → winner is still starting → 503 SERVER_NOT_READY
so the client retries via the readiness path (bounded, not indefinite).
- old claim, no job → the original already completed and was cleaned up
(or the winner died) → attach; the client's 404 handler refetches.
Tests cover both age branches.
* 🐛 fix: Scope dedup fail-open + keep resumed convos on 404 (Codex review)
- Fail-open only on claim acquisition: a store error while checking an
already-confirmed existing claim no longer falls through to createJob
(which would start a second billed generation during a Redis hiccup).
Once claim.existing is known, a job-lookup error returns 503 retry.
- Don't drop a resumed convo on 404: the optimistic-conversation cleanup
in useResumableSSE now runs only for fresh (non-resume) subscribes. A
deduped resume whose original completed and was cleaned up 404s, but its
conversation is persisted and must stay in the sidebar.
Adds a controller test for the job-lookup-error path (503, no createJob).
* 🐛 fix: Reconcile resumed convos on 404 instead of guessing (Codex review)
Round-4's !isResume guard fixed the completed-and-cleaned case (don't drop
a persisted convo) but left the inverse: a new-conversation retry deduped
to a claim whose original worker died before persisting still resumes,
404s, and — with removal skipped — leaves a phantom /c/<streamId> sidebar
entry.
Stop guessing keep-vs-remove on a resume 404. Reconcile against the
server: invalidate the conversations list so a real (persisted) convo
stays and a phantom is dropped. Fresh (non-resume) optimistic streams
still prune immediately. Adds a client test for the resume path.
* 🐛 fix: Finalize failed job before releasing its claim (Codex review)
In the initialization-error catch, the idempotency claim was released
before completeJob(streamId). A racing retry could win the released key
and createJob() the same streamId while this catch was still running, and
completeJob() (not guarded by the original createdAt) would then abort the
replacement. Finalize the failed job first, then release the claim.
Adds a controller test asserting completeJob precedes releaseGeneration.
* 🐛 fix: Clear claims on destroy + survive completeJob failure (Codex review)
- InMemoryJobStore.destroy() now clears the idempotencyClaims map, so a
reused/reconfigured store instance doesn't dedup a fresh start against a
torn-down job's stale claim.
- Init-error cleanup: completeJob() is swallowed so a store-hiccup
rejection can no longer skip the idempotency-key release and the
pending-request decrement (which would wedge the retry behind the claim
and leak the concurrency slot). A failed completeJob finalized nothing,
so releasing afterward still can't abort a later replacement.
Tests: claims cleared on destroy; release + pending decrement still run
when completeJob rejects.
* 🩹 fix: Route configured document types to RAG /text on agent upload
Restores pre-#11900 behavior for 'Upload as Text': when an admin narrows
fileConfig.text.supportedMimeTypes to a non-permissive allowlist that includes
a document type (docx/xlsx/pdf/ods/odt) and a RAG API is configured, the file
is sent to RAG /text instead of the built-in document parser.
The permissive default catch-all is excluded via isPermissiveMimeConfig, so RAG
deployments that never customized text handling keep the built-in parser. When
RAG is unreachable, parseText's new allowNativeFallback:false makes it throw so
the upload falls back to the built-in document parser rather than degrading a
docx/pdf to raw native-text bytes.
Fixes#14245
* style: sort imports in text.spec.ts (CI import-order)
* 🩹 fix: Scope RAG fallback catch to extraction only, not persistence
The configured-text branch wrapped both parseText and createTextFile in the
fallback try, so a persistence failure after a successful RAG extraction (size
guard, db.createFile, agent-resource mutation) was misread as RAG-unavailable
and retried with the built-in document parser, masking the real error and
risking a duplicate agent-resource mutation. Only the RAG extraction is now in
the fallback catch; a persistence failure surfaces as itself.
Addresses Codex P2 on #14345.
* 🔒 fix: Skip revoke for principals also being granted (owner-lockout guard)
bulkUpdateResourcePermissions flushes grants (upserts) before revokes (deletes).
If a principal appears in both updatedPrincipals and revokedPrincipals, the ACL
entry is granted and then immediately deleted, stripping access the caller just
set. This can strip a resource owner's own grant when the share dialog places
the owner in both lists from a client id/idOnTheSource mismatch (OpenID/Entra).
Add a server-side guard: track principals granted in the same request and skip
any revoke for the same principal, so granting wins and owner lockout is
impossible regardless of how the client computes the share diff. Complements the
client-side keying fix in #14317.
Refs #14316
* 🔒 fix: Exclude PUBLIC from grant-wins guard so public-disable is honored
The grant-wins guard must not apply to PrincipalType.PUBLIC. An explicit
public: false disable adds the public principal to the revoke list; a
contradictory payload that also grants public (public in the updated list) would
otherwise skip the revoke and leave the resource public. Disabling public access
must always win. User/group owner-lockout protection is unchanged.
Addresses Codex P2 on #14347.
* 🔒 fix: Move revoke guard inside per-principal try (tolerate malformed entries)
The grant-wins guard read principal.type before the per-principal try/catch, so
a malformed revoke entry (e.g. removed: [null]) would throw out of
bulkUpdateResourcePermissions after grants were already flushed on
non-transactional MongoDB, leaving partial permission changes. Move the guard
inside the try so a malformed entry is recorded in results.errors and skipped,
matching prior behavior.
Addresses Codex P2 on #14347.
Enhance the Vite configuration by including specific node polyfills in the `optimizeDeps` section. This change ensures that the necessary polyfills for `buffer`, `process`, and `global` are optimized for better performance during development. This adjustment aims to improve compatibility and streamline the build process.
When OPENID_REUSE_TOKENS is enabled and both the openidJwt strategy and the
HS256 jwt fallback fail, the final 'Authentication failed after all strategies'
warn log reported only the fallback's reason. For an RS256 provider (Keycloak,
Auth0, Okta) that surfaces as 'invalid algorithm', which is the HS256 fallback
rejecting the provider token, not the real reason openidJwt did not
authenticate, and it was previously only visible at debug level.
Include the captured primary (openidJwt) failure reason and error name in the
final warn log so reused-token failures are diagnosable without enabling debug
and are not misattributed to the fallback.
Refs #14311
* fix: stop agents from over-using the web_search tool
The web_search tool context injected into the agent system prompt was framed action-first ("execute immediately without preface") and never told the model when NOT to search. Models treat this as a default-on signal and search on nearly every turn, across all providers (Anthropic API, OpenRouter, etc.) since this instruction is provider-agnostic.
Reframe the instruction so the default is to answer from the model's own knowledge, gate searching behind a concrete trigger list, and explicitly reject the "a search might return relevant results" rationalization. Citation-format guidance is unchanged.
* refactor: make web_search guidance concise and neutral
Address maintainer feedback to keep the change minimal and generalized. Replace the verbose trigger list with a single neutral sentence: search when the user's request calls for it or when current/external information is required, and otherwise answer directly. Demote "execute immediately without preface" so it applies once a search is warranted rather than as the default, which was being read as "search on every turn."
* ✋ feat: Add Reclaim-Gated Controls to In-Flight Steers
Give a pending steer the same controls as a queued message — edit, convert
to queue, and the during-run mode toggle — instead of only a bare cancel.
Every re-homing action reclaims the steer from the server queue first and
acts only on a confirmed `removed: true`. A steer leaves that queue only by
injecting, so a lost race means the words are already in the run: queueing
or editing them then would say the same thing twice.
- Return a `SteerCancelOutcome` ('reclaimed' | 'applied' | 'failed') from
useSteerCancel so callers can distinguish "the words are still mine" from
"already injected" and "unknown fate" — the last two only toast.
- Extract RowMenu, useDefaultToggleEntry, and the shared button classes into
SteerMenu so both during-run surfaces use one implementation.
- Offer controls only once `pending`: a `sending` steer has no server id to
reclaim with, so its words cannot be held back.
- Pin the control cluster visible while its menu is open — the portaled items
hold focus outside the subtree, so `focus-within` alone would drop it.
* 🩹 fix: Address Codex Findings on Reclaimed-Steer Controls
Route a reclaimed steer through the shared conversion, and stop the async
reclaim from stranding items or clobbering a composer that moved on.
- Queue a reclaimed steer via useSteerConvert instead of enqueue, so it keeps
its original id and createdAt. enqueue minted a fresh v4/Date.now() and
appended, so a steer accepted BEFORE a later follow-up drained after it —
breaking the invariant the leftover-steer path documents.
- Submit the item directly when the run ended during the reclaim round-trip:
the drain consumes its one-shot signal against an empty queue, so nothing
was left to auto-send it. Read run state and conversation from refs, since
the reclaim resolves after the bubble unmounts.
- Refuse the composer restore when the origin conversation no longer matches
or a newer draft is present, and queue the words instead of overwriting
them. Neither text is the one to throw away.
- Split useSteerReclaim (POST only) out of useSteerCancel, so the menu actions
leave the chip alone until the outcome is known while the X stays optimistic.
* 🛡️ fix: Harden Reclaimed-Steer Guards Against Stale State
Both guards from the previous round read values that had moved on by the
time the reclaim resolved.
- Compare the origin conversation against a ref, not the closure. The `.then`
holds `restoreReclaimedSteer` from the render it was clicked in, so its
captured `conversationId` is the OLD chat — the guard compared that against
itself and passed, while `methods` (one form, reused across conversations)
wrote the steer into the chat now on screen.
- Gate the direct send on the drain's own rule. `!isSubmitting` also covers a
Stop or an error, so converting and then pressing Stop auto-sent the text
past useQueueDrain's completed-or-armed-interrupt-only rule. Capture the
run's outcome before the drain consumes the one-shot signal, and send only
on a clean completion of THIS conversation.
* ♻️ refactor: Re-Arm the Drain Instead of Direct-Sending Reclaimed Steers
The direct send was the wrong mechanism: it re-implemented the drain badly,
and each round of review found another rule it had skipped. Delete it and let
useQueueDrain do the sending — it already owns every one of those rules.
- Re-post the spent run-end signal under the conversation instead of calling
sendNow. The drain then applies the completed-only rule, FIFO order (an
older follow-up is no longer skipped), NEW_CONVO migration, and submits via
`ask` — which, unlike the composer's sendNow, does not reset the form and so
cannot wipe a draft typed while the reclaim was in flight.
- No-op when a signal is still armed: that drain has not run yet and will see
the item on its own, so arming a second carrier would send twice.
- Watch the parked run-end too, not just the index one, so a run that ended
while the user was in another chat is still seen.
- Treat staged files, quotes, and skill picks as a draft when deciding whether
a restore may overwrite the composer — editToComposer MERGES into them, so
restoring over staged context would glue two submissions together.
* 🎯 fix: Scope the Re-Arm Suppression to This Conversation
The no-double-arm guard treated ANY armed index run-end as proof the drain
would see this conversation's newly queued item. The index slot is shared:
useQueueDrain parks a foreign signal under its own conversation and then
inspects only the active one's queue, so a reclaimed steer sitting behind an
unrelated run-end would never be looked at and would strand until sent by hand.
Suppress only when the armed index signal belongs to THIS conversation — which
is the case where the drain really will see the item. The parked check was
already conversation-scoped by its key.
* 🧭 fix: Trust the Refs Only While They Describe This Chat
useSteering is reused across conversations, so after a navigation its live
refs describe the NEW chat while the reclaim's callback still speaks for the
old one. Restoring the conversation-identity guard I removed last round, which
was wrong precisely because the refs are live but not conversation-scoped.
- Skip the re-arm entirely once conversationIdRef no longer matches the steer's
conversation. Reading isSubmittingRef there could suppress a needed re-arm,
and lastRunEndRef could hold the NEW chat's run-end — parking that under the
old conversation would make drainNext (which keys off end.conversationId)
drain the wrong queue into the wrong chat.
- Assert lastRunEnd.conversationId matches before re-arming, so the invariant
is enforced where it is relied on rather than inferred from render order.
Nothing is lost by stopping: the item is already queued under its own
conversation, and that run's end parks under it and drains on return.
* 🗝️ fix: Key the Captured Run-End by Conversation
A single run-end slot could only answer for whichever chat was on screen when
a reclaim landed, so the guard had to bail on navigation — stranding a steer
whose run had already completed, contrary to "queue for after the response".
Key the captured run-ends by conversation instead. The stored end always speaks
for the chat the words belong to, so navigating away no longer suppresses the
re-arm, and another chat's end can never be parked under this one (which would
hand drainNext a foreign end.conversationId and drain the wrong queue).
- Drop an entry when its conversation starts another run: a superseded end must
not authorize a drain of the run now in flight. This replaces the isSubmitting
guard, which described the wrong chat after navigation.
- Remove conversationIdRef, now that no read depends on where the user is.
* 🧹 fix: Close Three Reclaim Races Around Answer Mode and Run End
- Refuse the composer restore while answer mode is active. `onSubmit` hands
composer text to `answerMode.submitText` before any send/steer routing, so a
restored steer would become the tool's answer on the next Enter. Read through
a ref: the run can pause on ask_user_question mid-reclaim.
- Skip the restore when a terminal conversion already queued the words. The
chip stays interactive during the reclaim round-trip, so a run ending or
erroring meanwhile converts it — restoring after would leave one copy queued
and another in the draft. The queue action needed no guard; the conversion
already dedupes by id.
- Carry quotes/skill picks on the reclaimed steer itself. The conversion
recovers them from the chip, which a competing X can delete mid-round-trip,
silently dropping the picks.
* 🎛️ feat: Fold Cancel Into an Always-Visible Steer Menu
Make the in-flight steer bubble a single, discoverable affordance instead of
two hover-hidden ones, matching how Codex/ChatGPT present the same control.
- Fold Cancel into the ⋯ menu as an item (X icon), removing the standalone X
button. It keeps the optimistic `useSteerCancel` path — no reclaim gate,
since cancel drops the words rather than re-homing them.
- Show the ⋯ at rest on every pointer instead of hover-gating it. A label-less
menu hidden until hover is undiscoverable on desktop and unreachable on touch;
always-visible also matches the queued rows' controls and drops the
hover/focus/menu-open opacity juggling entirely.
* 🪢 feat: Make Cancel and Queued Trash Non-Destructive
Both removal actions now hand their text back to the composer instead of
dropping it, so a message the user typed is never gone forever.
- In-flight Cancel: before cancelling, restore the words to the composer via
the gated `restoreReclaimedSteer` (skipped once applied — they are already in
the response). The restore refuses on its own rather than clobber a draft,
land in another chat, or fight answer mode; the cancel still runs reliably
either way, so an unwanted steer stays killable.
- Queued Trash: same safety net — thread the gated restore into the queued
rows and return the words (with their carried quotes/skills) to the composer,
then remove either way. Aligns the two surfaces on one behavior.
- Export the shared `RestoreToComposer` type so both surfaces reuse it.
Left the reliable-remove path intact (a steer sometimes must be killed before
it reaches the model) and did NOT reach for a delete+Undo snackbar, which the
shared Toast can't render without a cross-cutting action-button change.
* 🔒 fix: Restore Cancelled Steer Text Only on a Reclaimed Outcome
The cancel safety net restored the words to the composer synchronously, before
the cancel POST resolved. On `applied` (cancel lost the race, steer still
injects) or `failed` (POST errored, chip restored), the same text ended up both
in the run/bubble and in the composer.
Await `cancelSteer`'s outcome and restore ONLY on `reclaimed` — the one result
that proves the steer never reached the run. `applied`/`failed` leave the words
where the events place them, no composer copy. The gated restore still refuses
rather than clobber a draft typed during the round-trip.
* 🧵 fix: Never Drop Cancelled Text; Keep the Steer's Submit Time
Two follow-ups on the cancel safety net.
- Cancel no longer silently drops the words when the reclaim succeeds but the
composer refuses the restore (draft typed, answer mode, navigated). The chip
is already gone, so queue them like Edit does — never lost, just re-homed —
with the same toast.
- Preserve the true submission timestamp across submitSteer's chip states. The
ACK and failure chips reset createdAt to a LATER Date.now(), so a draft queued
during the 202 round-trip could sort ahead of a steer submitted before it and
drain out of order. Capture the submit time once and reuse it for all three.
* 🚪 fix: Refuse Reclaimed-Steer Restore Into an Unmounted Composer
A reclaim/cancel round-trip can resolve after ChatForm unmounts (left the
route, closed the pane). Its refs still hold the origin conversation, so
`restoreReclaimedSteer` passed its checks, wrote into a dead form, and returned
true — making the caller drop the steer instead of queueing it, losing the text.
Track mount state and refuse the restore once unmounted, so the caller queues
the words (recoil is global, so the queued chip survives the navigation).
`RedisEventTransport.emitChunk` awaited `INCR` (sequence allocation) and then
`PUBLISH` as two sequential round trips, per streamed delta. Fold both into one
Lua `EVAL` that allocates the sequence and publishes server-side.
Measured 50.6% reduction on the emit path (0.219ms -> 0.108ms per delta, 2000
deltas, loopback Redis). The saving multiplies by the token count of every
response, and scales with RTT: on setups where Redis sits behind a network
boundary (WSL2 loopback, cross-host, cross-AZ) at ~1-2ms/RTT this is ~0.5-1s on
a 500-token response.
The sequence is spliced into the payload server-side rather than round-tripped
through cjson, which would coerce empty arrays to objects and alter float
precision. The channel is passed as ARGV rather than KEYS: ioredis applies
`keyPrefix` to EVAL keys but never to a pub/sub channel, so keying it would
publish to a prefixed channel no subscriber listens on. PUBLISH is broadcast
cluster-wide rather than slot-routed, so it needs no key for Cluster
correctness.
Also parallelize `getResumeState`'s three independent job-store reads
(`getContentParts` / `getRunSteps` / `peekSteers`), collapsing 3 round trips
into 1 on every resume. Safe despite `readCachedGraph`'s cache-drop side effect:
each call catches its own unusable-graph throw and falls back to durable
reconstruction, so ordering cannot change the result.
`createMockPublisher` gains an `eval` that delegates to its own incr/publish
mocks, keeping the existing error-propagation tests meaningful now that
sequence allocation and publish are one operation.
#14272 gated the hover-revealed "..." button on hover capability, but pinned
agents still take two taps on iOS. That fix was aimed at the wrong mechanism
for this list.
Every favorite row is wrapped by DraggableFavoriteItem, and react-dnd's
HTML5Backend stamps `draggable="true"` on that wrapper unconditionally
(connectDragSource, HTML5BackendImpl.js:101 — `canDrag: false` does not
suppress it, react-dnd#2909). iOS Safari hands a touch on a draggable element
to the drag recognizer rather than synthesizing a click, so the row underneath
only selects on the second tap.
The draggable wrapper is what separates favorites from every other sidebar
row. Conversation rows are more hover-dependent than favorites ever were
(ungated `opacity-0 group-hover:opacity-100` plus an onMouseEnter that mounts
ConvoOptions) and select on the first tap.
Connect the drag source only under `(hover: hover)`. Nothing is lost on touch:
HTML5Backend has no touch support, so drag-to-reorder never worked there.
Passing null to the connector unsubscribes cleanly and resets the attribute,
so a hybrid pointer flipping the query re-arms drag.
* 🔒 fix: Scope, Cap, and De-Execute the In-Flight Steer Stack
Codex review on 9594ee7146. Three valid P2s, all fallout from moving the
steers out of the message region into the composer.
- Run scope: the in-thread slot was gated on `effectiveIsSubmitting`, but
the new one only checked `steering.enabled` (= steerable endpoint +
primary composer), which is true with no run in flight. A chip that
outlives its run — cancel's onError restoring one the final event
already converted to a queued follow-up — stranded a bubble above the
composer, possibly beside the queued row for the same text. Restores
the run gate.
- Height cap: a steer runs to 16k chars (DEFAULT_STEER_MAX_LENGTH) and a
run takes up to 10 (STEER_QUEUE_MAX_DEPTH). Unbounded in the composer,
that pushes the input off-screen; the old slot could grow freely
because it scrolled with the thread. Caps the stack at 35vh.
- Code execution: MarkdownLite defaults `codeExecution` on, but this
bubble renders outside MessageContext, so Run Code would fire the tool
mutation with no messageId and an empty conversationId. Passes
codeExecution={false} — a provisional steer has nothing to run against.
* 📜 fix: Keep the Newest In-Flight Steer in View
Codex review on de9ede2aad. Valid, and a regression from the 35vh cap in
the previous commit: steers append newest-last, so once the stack
overflows it sits scrolled to the OLDEST entry. The steer just submitted
— and its cancel control — lands below the fold and reads as dropped.
The cap traded "composer pushed off-screen" for "newest steer hidden".
Sticks the stack to the bottom, keyed on the newest steer id so it fires
when one is appended rather than on every render.
* 🧹 fix: Don't Restore a Steer That Already Settled
Codex review on 09c93987a. Valid, and it closes the hole the run gate
only hid — I deferred this two rounds ago as pre-existing, which was
wrong: the gate hides a stale entry while the run is idle, but
useQueueDrain auto-sends the queued follow-up, isSubmitting flips back
to true, and the previous run's entry renders as an in-flight bubble
beside its own queued copy.
Fixes it at the source instead: cancel's onError no longer restores a
steer whose id is in appliedSteerIdsByConvoId — the settled set, stamped
by both the apply path and the run-end conversion, and deliberately
capped rather than cleared so it survives run end for exactly this race
(same instrument as #14276).
The run gate stays: it's parity with the in-thread slot's
effectiveIsSubmitting and still defends against any other leak.
* 💬 refactor: Anchor In-Flight Steers Above the Composer
Mid-run steers were rendered in-thread at the tail of the streaming
assistant message, at a guessed injection point, then swapped to the
persisted STEER part at its real index once the server applied them.
In-flight steers now render as message bubbles anchored above the
composer, so the thread only ever shows what the server committed:
- InFlightSteers: sending/pending steers as left-aligned bubbles with
image previews and a cancel affordance, anchored above the composer box
- PendingSteerChips: unchanged, still owns the failed/queued control rows
- SteerPart: drops the pending/onCancel props, now only ever the
server-applied part
- useSteerCancel: the optimistic cancel + restore-on-error, lifted out of
the deleted PendingSteers slot
The steer state machine is untouched: the 202 ACK reconciliation,
reconnect reseeding, and queue conversion all key off status, not render
location.
* 🎨 fix: Match In-Flight Steer Presentation to the Applied Part
Codex review on 6a5f36f7ef. All three findings were real, and all three
were the same underlying mistake: the anchored bubble hand-rolled
presentation instead of reusing the leaves the applied SteerPart uses,
so a steer visibly changed on apply.
- Images: the message `Image` sets an inline height from the file's
dimensions and centers with object-contain, so clipping it into a 56px
wrapper showed the blank top of a large element. Use ImagePreview, the
composer's fixed-size thumbnail path (also gives click-to-enlarge).
- Non-image files: FileContainer always renders a button, so without an
onClick the chip was dead. Wire FilePreviewDialog, as SteerPart does.
- Markdown: honor enableUserMsgMarkdown so text does not reflow the
moment the server injects it.
Splits files in a single pass rather than two filters.
* 🎨 style: Outline the In-Flight Steer Bubble and Move the Bolt Inline
The filled bubble read as a settled message. An outline reads as
provisional, which is what an in-flight steer is, and separates it from
the composer surface behind it.
- Border + bubble keeps the composer's rounded-3xl radius so it reads as
anchored to the input rather than floating over it. Border stays
NEUTRAL: the failed-steer row already owns a colored (red) border, so
a colored outline on the happy path would read as a warning.
- The Zap moves inside the bubble, left of the text, where it prefixes
the words as a status label instead of competing with cancel for the
right edge. items-start pins it to the first line when text wraps.
- Cancel drops plain `opacity-0` for `[@media(hover:hover)]:opacity-0`,
matching SteerPart's info affordance: a hover-revealed control is
unreachable on touch until a first tap (the #14272 pattern).
* 🖼️ feat: Return Sandbox Images From `read_file` as Viewable Artifacts
The code-execution sandbox `read_file` path refused every image
extension because it reads files via `cat` over codeapi's JSON `/exec`
transport, which lossily corrupts non-UTF-8 bytes. The skill-file read
path already surfaced images as artifacts; this brings the sandbox path
to parity so an agent can actually see a chart/screenshot it reads.
- `readSandboxImage` (process.js): a Python base64 reader over `/exec`
with an in-sandbox size guard so oversize images never cross the wire;
base64 is ASCII-safe where `cat` corrupts.
- `handleSandboxImageRead` (handlers.ts): byte-integrity check (guards
against a truncated `/exec` stdout), MIME resolved purely from the
magic-byte sniff (extension only routes; a mislabeled non-image falls
back to the bash hint), and graceful degradation on every failure mode.
- Shared `buildImageArtifactResult` used by both read paths; the result's
`artifact.content` image_url reaches the UI (tool-end callbacks save it
as an attachment) and the LLM (SDK folds it into the model-visible
message for Anthropic/OpenAI/Google).
* ✅ test: Sync read_file code-only description assertions with image wording
* 🛡️ fix: Harden sandbox image reads (regular-file guard, completeness check)
Addresses Codex review on PR #14277:
- readSandboxImage now os.stat's the target and rejects non-regular files
(FIFOs, sockets, /dev/* symlinks) via stat.S_ISREG, and bounds the read at
limit+1 bytes — a device/FIFO can no longer stream unbounded into memory
until the request times out.
- handleSandboxImageRead validates completeness (not just the magic header):
PNG must end with the IEND trailer and WebP's RIFF size must match the byte
length, so a truncated/interrupted image degrades to the bash hint instead
of being sent as a corrupt image_url. JPEG/GIF stay header-level (they can
carry trailing metadata; a strict end-marker would risk false rejections).
* 🩹 fix: Chunk sandbox image reads to fit the runner stdout cap
Inlining any real image failed with "is an image file (.png) and cannot
be read as text". Root cause: readSandboxImage base64-encodes the file to
STDOUT, but the runner caps stdout at SANDBOX_OUTPUT_MAX_SIZE (1024 bytes
by default) and SIGKILLs the job on overflow (status OL), truncating the
JSON mid-base64. The parse then threw and the handler degraded to the
binary hint. The in-sandbox MAX_BINARY_BYTES=5MB guard never fired because
the *transport*, not the file size, is the real ceiling: a 5MB image needs
~6.8MB of stdout. Reproduced against a live MicroVM — a 186KB matplotlib
PNG died with 'stdout length exceeded' at exactly the 65536-byte cap.
Read the file in windows instead: each /exec pulls raw bytes at an
offset and base64s only that slice, so every response stays under the cap
regardless of how the runner is configured; the chunks are reassembled and
verified against the sandbox-reported total. Verified end-to-end on a real
MicroVM: 25KB and 186KB PNGs both round-trip byte-exact (sha256 match).
Also:
- Detect the truncation explicitly (status OL) and name the fixable cause
(chunk size / SANDBOX_OUTPUT_MAX_SIZE) instead of "unexpected output".
- Parse the LAST stdout line so a shell banner can't break the read, and
include a stdout snippet when it genuinely is unparseable.
- LIBRECHAT_CODE_IMAGE_CHUNK_BYTES (default 32KB) tunes the window.
- Tests drive the real reader against a mocked /exec transport rather than
mocking readSandboxImage, which is why the existing suite stayed green
through this bug.
* 🎯 fix: Cap sandbox inline images at 1MB, separate from skill-file reads
The sandbox and skill-file image paths shared MAX_BINARY_BYTES (5MB), but
their transports differ: skill files stream from storage, while sandbox
bytes come back base64 over /exec stdout under the runner's output cap, so
the reader windows the file and cost scales in round-trips (~160 at 5MB vs
~32 at 1MB). Nothing is gained by allowing more — vision providers
downsample to ~1.5-2k px regardless, so multi-MB originals buy no fidelity
while grinding through round-trips.
Give the sandbox path its own MAX_SANDBOX_INLINE_IMAGE_BYTES (1MB), used
for both the read cap and the over-limit message (which previously quoted
5MB while the reader enforced something else). Skill-file reads keep 5MB.
Verified against a live MicroVM: a 186KB PNG round-trips byte-exact, and a
1.4MB file returns tooLarge in a single round-trip with zero bytes
transferred, degrading to the existing bash_tool hint.
Removing the last file from an agent's Context panel fired no request and
the file reappeared on reload.
`FileContext` mounts `FileRow` only while `fileCount > 0`, and
`useFileDeletion` lives inside `FileRow` behind a 1s debounce whose unmount
cleanup called `debouncedDelete.cancel()`. Deleting the last file drops the
count to 0, unmounting `FileRow` and cancelling the delete the user had
already confirmed. Removing a non-last file kept the row mounted and worked,
which made the failure look erratic. The same cancel also dropped deletes in
any panel closed within the debounce window.
Flush the pending batch on unmount instead of cancelling it.
`loadTools` contracts every `requestedTools` entry as `() => Promise<Tool>`
and the loader relies on it via `validTool().catch(...)`.
The `ask_user_question` factory was registered synchronously, and
`createAskUserQuestionTool()` returns a `DynamicStructuredTool` directly, so
the call returned a tool with no `.catch`, throwing
`TypeError: validTool(...).catch is not a function`.
The throw happens inside the loop before `Promise.all`, so it aborted the
entire tool load for the turn, not just this tool: agents with
`ask_user_question` attached ran with no tools at all.
* 🧹 fix: Drop Pending Composer Draft When Steering or Queuing
A during-run submit takes the composer text into a steer or a queued item
and clears the composer via the form's `reset()`. That clear is
programmatic, so it never fires the `input` event `useAutoSave` listens
on, leaving the autosaved draft (keyed under `PENDING_CONVO` for the
duration of the run) behind.
When the run ends, `useAutoSave` migrates a surviving pending draft onto
the real conversation id and restores it into the textarea. The result:
a queued message that was successfully auto-sent by the run-end drain
immediately resurfaced as the composer draft, and persisted there under
the conversation key across reloads.
Consume the pending draft at the three composer-origin entry points
(steer, queue, interrupt & send), mirroring the existing
takeComposerFiles/takeComposerContext consumption helpers. Only a
consumed submit clears it — a refused one (empty text, uploads in
flight) leaves the draft intact.
* 🔒 fix: Flush The Live Composer Value On Debounced Autosave
Codex round 1: the 25ms debounced autosave captured the textarea value at
event time, so a write scheduled just before a during-run steer/queue
could land after the composer was consumed and cleared — rewriting the
just-sent text back into the PENDING_CONVO draft and defeating the clear.
Read the value at flush time instead. When the composer was cleared in
the debounce window the pending write now removes the draft rather than
resurrecting it, and an untouched composer saves exactly as before.
* 👀 fix: Reveal the Steered "?" on Message Hover/Focus
The steered-message "?" InfoHoverCard sat on every steered message at
rest. Wrap it like the message hover buttons so it stays transparent
until the message is hovered (group-hover) or the trigger is focused
(focus-within), keeping the thread clean.
* 📱 fix: Keep the Steered "?" Visible on Touch (Codex)
Plain opacity-0 hid the info affordance on touch entirely, with no hover
path to reveal it. Gate the hidden-at-rest state on hover capability
([@media(hover:hover)]:opacity-0), matching the message hover controls:
visible on touch, revealed on hover/focus on hover-capable pointers.
The pin/options buttons on these items were hidden-until-hover on ALL
pointers (invisible / opacity-0), making each item's rendering
hover-dependent. On touch that triggers the iOS "first tap reveals the
hover state, second tap activates" behavior, so selecting a model
spec, model, or pinned/favorite agent took two taps.
Gate the hover-reveal on hover CAPABILITY (the same fix#13712 applied
to message hover buttons): the control is visible/tappable by default
and only hidden-until-hover on hover-capable pointers via
[@media(hover:hover)]. On touch the item is no longer hover-dependent,
so the first tap selects.
- ModelSpecItem / EndpointModelItem: pin button reveal
- FavoriteItem (sidebar pinned agents): options button + wrapper reveal
* 🐛 fix: Strip duplicate `system` from Bedrock additionalModelRequestFields
Bedrock Anthropic presets bind the system prompt to the `system` model
param. bedrockInputParser routes `system` into additionalModelRequestFields,
then bedrockOutputParser promotes it back to the root as a known key without
removing the copy. Bedrock Converse then sees `system` in both places and
rejects the request ("The additional field system conflicts with an existing
field"), which surfaces once context compression/summarization runs.
Delete `system` from additionalModelRequestFields after promoting it to the
root. `system` is the only leaked field that collides with a reserved
top-level Converse field, so the fix is scoped to it and leaves other
passthrough fields untouched. Clones before mutating to avoid touching the
caller's input.
Closes#14029
* 🛡️ fix: Guard scalar additionalModelRequestFields before `in` check
DocumentType permits scalar values (boolean/number/string), so a saved
Bedrock preset/agent can carry a non-object additionalModelRequestFields.
The new `system` cleanup used `'system' in amrf`, which throws
TypeError on a truthy scalar. Guard with a typeof-object check to keep the
prior tolerant behavior; the empty-check is left unchanged.
* 🛡️ fix: Strip all reserved Converse fields from additionalModelRequestFields
* 💬 feat: Explain Steering & Queuing at Every User-Facing Surface
Adds localized info affordances so users understand what steer / queue /
interrupt do wherever the feature appears, using the app's existing
norms (InfoHoverCard in Settings, TooltipAnchor in the composer, inline
descriptions in menus).
- Settings → Chat: an InfoHoverCard "?" beside the during-run action
toggle explaining steer vs queue (matches every neighboring setting)
- During-run send button: the hover action list gains a header and a
one-line description under each action (steer / queue / interrupt),
turning the menu into a self-explaining card
- "Turn on steering / queueing" overflow entry: a description sub-line
explaining the mode the user would switch to (the label alone doesn't
say what steering/queueing means)
- Steered in-thread message: a subtle "Steered" badge with a tooltip
clarifying why a user message appears inside the assistant response
- New English keys only (others automated)
* 🔤 style: Sort SteerPart imports (repo import-order)
* 🎐 refactor: Subtle "?" Info Affordances for Steering (Feedback)
Reworked the info UI toward the app's "?" InfoHoverCard norm and away
from always-on text / a loud badge:
- Steered message: replaced the amber "Steered" pill with a subtle "?"
InfoHoverCard in the header (the norm; muted, hover-reveals why a user
message appears inside the response)
- During-run send button: reverted the per-action description lines —
the hovercard is already a hover-reveal affordance, so it stays a
clean action list (labels + shortcuts)
- "Turn on steering/queueing" overflow entry: reverted the description
sub-line back to a clean menu item
- Settings → Chat "While generating, Enter will…": keeps its "?"
InfoHoverCard (the canonical, discoverable explanation)
- Pruned the 5 now-unused i18n keys; kept com_nav_info_during_run_action
and com_ui_steered_info
* 💭 fix: Default GPT-5.6 Reasoning Requests to Responses API
GPT-5.6 models reject function tools combined with `reasoning_effort` in
/v1/chat/completions (400: "Function tools with reasoning_effort are not
supported ... To use function tools, use /v1/responses or set
reasoning_effort to 'none'"). Default `useResponsesApi` to `true` for
GPT-5.6 models on the OpenAI endpoint when a reasoning effort other than
'none' is requested, unless the user explicitly set `useResponsesApi`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 💭 fix: Address GPT-5.6 Responses API guard review feedback
- Skip the auto-switch for OpenRouter-backed OpenAI endpoint configs;
OpenRouter keeps its own reasoning path
- Respect `dropParams: ['useResponsesApi']` as an explicit opt-out so
Chat Completions requests never carry a nested reasoning payload
- Stop treating `dropParams: ['reasoning']` as dropping reasoning:
`deleteConfigParam` only removes the nested object, so skipping the
switch there would resend flat `reasoning_effort` and re-trigger the 400
- Reword docstring: reasoning without tools still works on Chat
Completions; the default exists because tools bind after config time
- Reconcile with #14233: the Chat Completions mode/context omission test
now opts out explicitly, and a new test locks in that the Responses API
default carries reasoning mode/context in the nested reasoning object
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 💭 fix: Scope GPT-5.6 Responses API default to first-party OpenAI
- Skip the auto-switch for custom gateway base URLs (reverseProxyUrl/
directEndpoint may expose only /v1/chat/completions); the Responses
default now applies to canonical api.openai.com only
- Skip when reasoningFormat is 'disabled': no reasoning payload is sent,
so there is no tool incompatibility to avoid and the request stays on
the configured Chat Completions path
- Add coverage for custom gateway base URL, canonical base URL, and
disabled reasoning format
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
* 🧹 fix: Terminate Streamable HTTP MCP sessions on teardown to prevent server-side leaks
The MCP client only called client.close() when discarding a Streamable HTTP
connection, never the spec-mandated HTTP DELETE with the Mcp-Session-Id header.
Stateful MCP servers (the SDK default) keep each session's transport, tasks, and
buffers in memory with no TTL, so every idle eviction, reconnect, or restart
leaked one server-side session.
Add terminateStreamableSession() and call it before client.close() (which aborts
the transport controller and would cancel the in-flight DELETE) on both teardown
paths: disconnect() and the reconnect transport swap. It is bounded by a 5s
timeout so teardown never blocks on a hung server, and swallows errors since
terminateSession() already no-ops without a session id and tolerates a 405.
* 🛡️ fix: Suppress reconnect on failed session DELETE; harden termination tests
Address Codex review on #14252.
- P2: terminateSession() invokes transport.onerror before rejecting on any
non-405 failure (session already expired, network error, or client.close()
aborting after the timeout). Our handler turned that into
connectionChange('error') -> handleReconnection(), reopening the connection
mid-teardown and re-leaking the session. Detach transport.onerror before
terminating; the transport is discarded immediately after, so it is safe.
Add a regression test that fails before this change.
- P3: bind test HTTP servers directly to an ephemeral port (listen(0) + read
address) instead of probing a port and rebinding, removing a bind/listen
race under parallel CI.
- P3: null-init the server handles and guard close() so a startup failure
surfaces the real error instead of a masking TypeError.
When a run pauses for `ask_user_question` (or tool approval), the paused turn's
client is disposed and `disposeClient` (api/server/cleanup.js `graphPropsToClean`)
runs `clearHeavyState()` and then NULLS the graph's internal arrays (`messages`,
`contentData`). `RedisJobStore` still holds a `WeakRef` to that graph in
`localGraphCache`, so on resume/reconnect `getContentParts()` / `getRunSteps()`
deref the disposed graph and call `graph.getContentParts()` (`this.messages.slice()`)
/ `graph.getRunSteps()` (`[...this.contentData]`), throwing "Cannot read properties
of null (reading 'slice')" / "this.contentData is not iterable" and aborting the
resume. Redis-only: InMemoryJobStore reads `state.contentParts` /
`graph?.contentData ?? []` directly and never calls the throwing SDK getters, which
is why `USE_REDIS_STREAMS=false` works.
RedisJobStore now reads the cached graph through `readCachedGraph()`, which tolerates
a disposed graph: it swallows the deref error, drops the stale cache entry, and lets
the caller fall back to durable chunk reconstruction instead of crashing.
The true root cause (the unguarded null deref) is in `@librechat/agents`
`StandardGraph.getContentParts()` / `getRunSteps()` and is addressed there
separately; this is the host-side defensive guard.
Fixes#14247. Addresses Bug 3 of #14253.
* 🧭 feat: Mid-Run Steering and Queued Messages for Agent Runs
Steering: submit a message while a run is generating; the server queues
it in the job store (cross-instance) and a run-scoped PostToolBatch hook
injects it into graph state at the next tool-batch boundary, records an
inline 'steer' content part on the response (replayed as a user message
on later turns), and streams on_steer_applied to the client.
Queuing: messages composed during a run auto-send as normal follow-up
turns after clean completion (one per final event, FIFO); user aborts
leave them as chips unless armed by interrupt-and-send.
Requires hook injectedMessages support in @librechat/agents
(danny-avila/agents#299); hard-gated via a capability probe so older
SDKs 501 the steer route instead of draining and dropping messages.
* 🧵 fix: Harden Steering Against Finalization Races and Route Guard Gaps
Addresses local Codex review findings on the steering feature:
- Close-and-drain the steer queue atomically at finalization (final event,
abort) so a steer POST racing teardown is rejected instead of 202-ACKed
and then silently cleared; the closed flag lives on the job hash and is
reset when a replacement job reuses the stream id.
- Clear inherited steer queues on createJob — a job replacement must not
drain the replaced run's messages.
- Keep steers queued across a HITL pause instead of draining them into
ephemeral client state: resumeState re-seeds chips on reload and the
resumed run injects them at its first tool boundary (steers key TTL now
extends to the approval window; on_steers_pending event removed).
- Queue the NO_ACTIVE_RUN steer fallback while the final SSE is still
settling — a direct send would be dropped by ask()'s in-flight guard.
- Reconcile the 202 ACK against on_steer_applied events that beat it over
the SSE, so a chip can't be re-minted after its removal event passed.
- Allow the per-send Steer override when the default action is queue.
- Apply the configured message rate limiters and the PII filter to
POST /chat/steer — a steer is model-bound user text.
* ✅ ci: Assert Steering Capability Probe Against the Installed SDK
CI installs the published @librechat/agents pin (pre-injectedMessages),
where isSteeringSupported() is legitimately false — the probe test now
asserts it mirrors the installed SDK's capability flag instead of
hardcoding the capability-bearing build's value. Verified against both
the published 3.2.61 dist and the agents#299 build.
* 🛟 fix: Preserve Steer Text Across Run-End, Error, and Abort Races
Codex round 2 (4 P2s):
- Applied-steer-id set survives run end (capped at 100) and converted
ids join it, so a 202 ACK that lands after final/abort drops its chip
instead of re-minting a stranded pending one.
- Failed runs no longer strand acknowledged chips: both error paths
convert local pending chips to queued follow-ups (chip text is
client-local), and the server closes the steer queue before emitting
the error so a racing steer POST gets 404 fallback instead of a 202
whose payload dies with the job.
- sendQueuedNow keys on steer availability, not the default action —
send-now on a queued chip is an explicit override for queue-preferring
users.
- Stop path consumes pendingSteers from the abort HTTP response as a
fallback for the SSE final event it may close before processing;
conversion is deduped so double delivery is a no-op (shared
useSteerConvert hook).
* 📎 feat: Carry Attachments Through During-Run Queued Messages
Steering stays text-only (SDK injection, inline STEER part, and replay
are all text), so a during-run submit with media now queues the whole
message as one unit instead of silently stranding the files:
- QueuedMessage gains `files`; composer attachments are consumed into
the queued item at queue time (steerFromComposer / queueFromComposer /
interruptAndSend), fixing the latent hazard where lingering composer
files glued onto whatever `ask` vacuumed up next.
- Enter-steer with attachments degrades to queue with an explanatory
toast; the per-send menu routes through the same composer-aware
wrappers.
- The drain and sendQueuedNow pass the item's files as `overrideFiles`;
media items never steer (send as a normal turn when idle, re-front
otherwise). ask() no longer clears composer state for caller-supplied
overrideFiles — only regenerate keeps that behavior.
- During-run submits hold while uploads are in flight, mirroring the
send button's filesLoading gate; queued chips show a paperclip count.
* 🎛️ feat: Rework During-Run Chips into Action Rows
Full-width rows above the composer (reference-UI parity): each queued
message shows a primary Steer/Send-now action, delete, and a "…" menu
with Edit message (restores text + attachments into the composer) and a
Turn on queueing/steering toggle that flips the Enter default. Steer
rows share the layout with status text; failed steers keep retry /
edit / queue-convert. The per-send menu gains the same default toggle.
Queued file refs now retain filename + bytes so edit-restore rebuilds
real composer entries (draft-recovery shape).
* 🖇️ feat: Steer With Attachments (Multimodal Mid-Run Injection)
Steering now carries media end-to-end instead of degrading to queue:
- The steer POST accepts sanitized attachment refs (cap 10; only
file_id is trusted — the drain re-fetches owner-scoped and re-derives
everything else). SteerQueueItem/TPendingSteer/SteerContentPart carry
`files` refs; encoded data is never persisted or queued.
- New api/server/services/Files/steering.js decouples attachment
building from the request path: encodeSteerContent reuses the exact
per-turn pipeline (addFileContextToMessage + processAttachments'
single-pass categorize/encode, SDK formatMessage assembly,
prependFileContext for extracted text) with zero new encoding code.
buildSteerMedia feeds the drain hook's new buildMedia seam (any
failure degrades that steer to text-only — words always land);
stampSteerPartMedia re-encodes past steer parts per turn with ONE
batched owner-scoped fetch and stamps a transient `media` array,
replaced immutably so it can never leak into a save. Replay honors
resendFiles like regular message media.
- The SDK's formatAgentMessages (the formatter agents actually use)
gained the steer replay branch on the PR branch; the local
formatMessages.js branch now mirrors the media preference.
- Client: steerFromComposer consumes composer files into the POST,
chips/seeding/conversions carry files everywhere (retry, queue
convert, abort/error recovery), queued media items steer for real,
and SteerBubble renders the steered attachments inline.
* 🧵 fix: Harden Steer Recovery Races and Drain Isolation
Codex round 3 (7 fixes):
- A 202 ACK landing after the run ended converts straight to a queued
follow-up (server queue is gone; no event will ever resolve a pending
chip for a finished run). Covers stream errors with in-flight POSTs.
- A Stop that lands pre-completion can arrive as a final with
unfinished:true and no aborted flag — runEnd now treats it as aborted
so queued messages are not auto-sent against the user's Stop.
- Leftover-steer conversion merges chronologically by createdAt instead
of appending, preserving the order the user composed.
- Auto-drained queued messages pass explicit (possibly empty)
overrideFiles/overrideQuotes/overrideManualSkills: a drain can no
longer vacuum up files, quotes, or skill picks staged in the composer
for the user's NEXT message (ask() treats overrideFiles != null as
authoritative).
- Failed-steer Retry and resume-on-load chip restoration keep the
steer's attachments.
- The job-replacement guard moved INSIDE the store's atomic
drain/close-and-drain (Lua createdAt compare; in-memory equivalent):
a stale run's hook or finalization can neither consume, close, nor
steal a replacement job's steer queue, and the drain hook drops its
separate check-then-drain round trip.
* 🧰 refactor: Typed Steer Controller, Single-Query Media Pass, Round-4 Fixes
Codex round 4 + efficiency tightening in one pass:
- Moved the steer guard ladder (validation, file sanitization via a
shared toSteerFileRef picker, ownership/tenant checks, status-guarded
enqueue) into packages/api as handleSteerRequest; api/steer.js is now
a thin wrapper. Ladder covered against the REAL in-memory job manager
in request.spec.ts; the api spec pins only the wrapper contract.
- Folded the steer replay stamp into the turn's ONE historical-files
query: collectHistoricalFileRefs also gathers steer-part refs, the
owner-scoped doc map rides client state, and stampSteerPartMedia
consumes it (no second round trip) while encoding parts in parallel.
- Stamped steer media now counts against the run budget (existing
multimodal counter over the non-text parts, folded into
indexTokenCountMap/promptTokens after the stamp).
- Steer route runs the PII filter BEFORE moderateText, matching chat.js
so blocked sensitive text never reaches the external moderation API.
- Interrupt & send survives the abort-response-beats-SSE-final race:
stopGenerating writes the run-end signal itself when the one-shot
interrupt flag is armed and no signal landed (double-fire safe).
- Resume reconciles chips against the server's still-queued list even
when EMPTY, clearing chips for steers applied while disconnected.
- The local formatter's steer flush preserves non-text assistant parts
(array-content AIMessage) instead of folding to text.
* 🔒 fix: Replay-Aware Capability Gate and Round-5 Race Closures
- isSteeringSupported now requires BOTH halves of the SDK contract:
injection (HOOK_INJECTED_MESSAGES_CAPABLE) AND replay
(ContentTypes.STEER, shipped in the same SDK commit as the
formatAgentMessages steer branch). An SDK that can inject but not
replay 501s the steer route — no release window can create steer
parts that would leak into provider-facing assistant content.
- The local formatter mirrors the SDK's anchor reset: a post-steer
tool_call mints a fresh AIMessage instead of attaching to the
pre-steer anchor (invalid provider ordering).
- Queued-chip send-now and the NO_ACTIVE_RUN fallback pass explicit
(possibly empty) overrideFiles so an idle send can't vacuum composer
files staged for a different draft.
- Redis createJob deletes the stale steer list BEFORE the replacement
hash is written as running — a steer 202-accepted against the new job
can never be wiped by the reset.
- Resumed-turn finalization mirrors the normal path's terminal drain:
createdAt-guarded close-and-drain, leftovers ride the resumed final
event as pendingSteers instead of being cleared by completeJob.
- buildSteerMedia restores composer order over the $in result so
multi-attachment steers reach the model in the order the user saw.
* ⚛️ fix: Atomic Job Replacement and Boundary-Clean Steering Module
Codex round 6 (5 fixed, 1 standing deferral):
- createJob resets the steer queue and writes the job hash in ONE
same-slot Lua script (JOB_CREATE_LUA): a steer POST can no longer
interleave between them on cluster, so a steer accepted against one
run can never be drained into another. Redis-validated.
- The steering media pipeline moved to packages/api
(agents/steering/media.ts) with injected getFiles and a structural
client interface — /api keeps zero steering logic; specs ported to
the DI seam.
- handleSteerRequest checks the job BEFORE the capability gate: a steer
racing completion on an unsupported SDK gets 404 (send-now) instead
of a 501 queue with no run-end signal left to drain it.
- useQueueDrain binds to the active conversation: navigating away
between the final SSE and the drain effect leaves the signal
unconsumed instead of submitting A's follow-up into B; the drain
fires on return.
- abortJob closes and drains the steer queue BEFORE the content
snapshot, so a drain-hook apply that lands pre-drain is captured
inline rather than lost between the snapshot and the terminal drain.
* 🚦 fix: Parked Run-End Signals, Interrupt Priority, Settled-Run Fallbacks
Codex round 7 (5 fixes):
- Run-end signals for a non-active conversation are PARKED per
conversation instead of squatting the shared index slot: a later run
finishing on the same pane can no longer overwrite them, and the
parked drain fires when the user returns.
- "Interrupt & send" front-inserts carry a priority flag that outranks
createdAt when abort leftovers merge back chronologically — the
urgent redirect drains first, not the oldest steer.
- STEER_UNSUPPORTED/RUN_PAUSED/QUEUE_FULL rejections landing after the
run settled mirror the NO_ACTIVE_RUN fallback and send immediately
(queueing would strand the text with no run-end signal left); on the
pinned SDK this is the common Enter-near-run-end path.
- A failed abort (e.g. 404 when the run completed first) still signals
the interrupt drain, so the queued interrupt message can't strand and
the armed flag can't leak onto a later run.
- Steered-image fallback alt text is localized (com_ui_attached_image).
* 📌 chore: Adopt Published @librechat/agents Types Post-Bump
dev's pin bump to ^3.2.62 (the release carrying injection + steer
replay) landed via merge; the steering runtime now uses the SDK's real
InjectedMessage/hook-output types instead of the local structural
mirrors that bridged the pre-publish window. The two-half capability
probe stays as the defensive gate for mismatched deployments — and the
capability spec now exercises its TRUE path against the published
package in CI.
* 🛅 feat: Park-and-Claim Steer Recovery + Host-View Content Reads
Codex round 8 (6 fixed incl. both P1s, 1 push-back):
- The long-deferred no-subscriber gap is closed: every terminal drain
(final, aborted-final, error, abortJob, resumed finalize) PARKS
acknowledged leftovers on the job hash (unrecoveredSteers), and the
status route claims them exactly once for inactive jobs — a client
that closed/reloaded past the transient final event restores its
steers as queued chips within the post-terminal TTL. A replacement
run clears the parked copy (a live client started it).
- Same-instance content reads are steer-complete: RedisJobStore now
caches the HOST content array (WeakRef) via setContentParts and
prefers it over the SDK graph cache, whose view never contains
host-authored steer parts; the graph fallback splice-INSERTS steer
chunks at their recorded host-view indices (the graph array is
unshifted, so assignment would overwrite SDK parts).
- Replay token accounting now counts prepended file-context text: full
stamped content minus the steer body (already counted), so large
steered documents hit the budget instead of bypassing pruning.
- The queue drain restores an item when ask() refuses without sending
(history not yet in cache after navigating back) — text is never
silently dropped.
- The armed interrupt flag travels WITH a parked run-end signal, so
another run on the same pane can neither consume nor clear it.
- parseTextParts extracts steer text (search indexing / audio).
* 🎛️ refactor: Single Send Slot + In-Thread Steer Messages
- Merge the during-run send affordance into the send/stop button slot:
with composer text the send button replaces Stop (Enter = default
action), hover reveals Steer/Queue/Interrupt rows with shortcuts;
drop the separate DuringRunActionsMenu chevron
- Add during-run keyboard chords: Cmd/Ctrl+Enter = non-default action,
Alt+Enter = interrupt & send (plain-Enter submitters only)
- Render steers as standard user messages in the thread: SteerPart
(icon + author header + user text presentation) replaces the
SteerBubble, and submitted steers appear immediately at the projected
injection point via the PendingSteers slot on the streaming message
- Keep composer rows only for recoverable states: failed steers
(retry/edit/queue) and queued follow-ups
* 🩹 fix: Keep the Replacement Submission Alive Across Abort Settlement
The aborted run's final SSE event fires before the abort HTTP response
resolves, so an armed interrupt & send drains and starts the NEXT
submission while the abort POST is still in flight. The response
handler's unconditional clearAllSubmissions() then reset the new
submission, aborting its stream attach before the subscribe — the
follow-up ran and persisted server-side but the live placeholder
finalized empty (content appeared only after reload).
useAbortCleanup captures the submission before the abort round-trip
and both settlement paths (success and 404-catch) clear only when the
captured submission is still current; a replacement stays untouched.
Plain Stop behavior is unchanged.
* 🧭 test: Playwright E2E for Mid-Run Steering and Queuing
- Add e2e/specs/mock/steering.spec.ts: steer mid-run (202 + immediate
in-thread pending part + real MCP tool boundary + words survive run
end), Cmd/Ctrl+Enter queue with auto-send after clean completion,
and Alt+Enter interrupt & send with the follow-up streaming into the
live view
- Add the E2E_STEER_TOOL_REPLY fake-model marker: slow preamble, a
real remember_fact MCP tool call (PostToolBatch boundary), then a
final turn
- Test 1 pins the run-end degradation contract while the SDK's
top-level agentId stamping bug blocks live injection; its header
documents the assertions to flip once the fixed SDK is pinned
* 🧷 fix: Job-Independent Steer Recovery + Expiry and Resume-Gap Parking
Codex round 10: the park-and-claim recovery had lifecycle holes.
- Move parked steers off the job hash onto their own bounded-TTL store
key (JOB_CREATE_LUA resets it; deleteJob leaves it alone): the default
completeJob path deletes the job record immediately, and the Redis
read path never deserialized the old hash field — recovery previously
worked only with STREAM_KEEP_COMPLETED_JOBS on the in-memory store
- Carry the owner identity inside the parked payload and authorize the
claim against it, so the status route recovers steers on its jobless
branch too (the common reload-after-terminal case); a non-owner claim
returns nothing and re-parks the payload
- Park queued steers on approval expiry: snapshot the frozen queue
before the requires_action→aborted CAS (whose terminal cleanup drops
the steers key) and park only when the CAS wins
- Mirror the terminal drain/park block in resume.js's failure path,
which previously let completeJob's backstop clear 202-accepted steers
- Close the Redis snapshot→subscribe resume gap: re-peek the queue
after attaching and re-surface missed on_steer_applied events from
the durable content view (synthesizeAppliedSteerEvents), updating
resumeState.pendingSteers to the live queue
* 📌 chore: Require @librechat/agents 3.2.63 + Applied-Steer E2E Contract
- Bump the @librechat/agents pin to ^3.2.63 in api/ and packages/api/:
it scopes the hook agentId marker to subagent child graphs, so the
steering drain hook fires at top-level tool-batch boundaries and
mid-run injection is active (danny-avila/agents PR 307)
- Flip e2e steering test 1 from the documented degradation contract to
the applied-steer contract: the optimistic in-thread part transitions
to the persisted part at the tool boundary and survives inside the
response after run end, with no queued follow-up turn
* 🎗️ feat: Steered Messages Join the Message-Nav Ribs
Steers are user messages, so they get their own clickable rib on the
navigation rail, interleaved at their in-thread position inside the
response that absorbed them (one DOM query in document order). SteerPart
anchors itself as #steer-<id> with a steer-render marker — both the
optimistic pending entry and the persisted part — and the rib carries
the user role label with a preview drawn from the steer's text body,
skipping the author header.
* ❎ feat: Cancel a Queued Steer Before Injection + True User-Message Alignment
- Add POST /chat/steer/cancel: removes ONE still-queued steer by id via
an atomic list rebuild (Redis Lua preserves order and TTL), authorized
against the job owner; removed:false is advisory — the cancel lost its
race to the drain or the run end, never an error
- Surface an × on the in-thread pending steer (server-acknowledged
entries only): optimistic removal, restored if the POST fails since
the server would still inject the words
- Outdent SteerPart past the response's icon column so steers sit flush
with top-level message rows, reading as regular user messages
* 🧯 fix: Round-11 Recovery Hardening + Provider-Free Pending Slot
- Reconcile the resume steer gap by steerId SETS, not queue length — a
steer added in the gap (or an equal-length drain+enqueue swap) now
refreshes resumeState.pendingSteers and still synthesizes the missed
on_steer_applied events
- Make completeJob's terminal backstop park: direct error-path callers
without the controllers' close-and-park no longer silently clear
202-accepted steers (createdAt-guarded closeAndDrain + owner park
before the terminal write)
- Persist the steer part BEFORE media encoding in the drain hook: an
abort inside the encode window can no longer lose a file-steer (the
part refs come from the enqueue-sanitized item; replay re-encodes
per turn unchanged)
- Move the parked-claim owner check INSIDE the atomic store claim
(substring gate in the Lua / in-memory equivalent): a non-owner probe
can no longer transiently delete the recovery payload; the app-side
parse stays authoritative
- Park queued steers in BOTH stores' own requires_action expiry
cleanup, which bypassed the manager-level sweep
- Sweep expired parked steers from the in-memory store's periodic
cleanup; restore a queued chip when send-now's submit is refused;
upsert steer ACKs so an SSE reconnect reseed cannot duplicate chips
- Mount the cancel mutation per steer item so the pending slot needs no
QueryClient on ordinary streaming renders (fixes the CI failure in
ContentParts.integration.test)
- Skipped delivery-gated parking (finding 8): transport receiver counts
cannot prove browser delivery, and gating the only durable copy on
them trades cosmetic chip resurrection for real text loss; the window
is already bounded by claim-on-read, createJob reset, and the TTL
* 🩺 fix: Annotate PARKED_STEERS_TTL_MS for isolatedDeclarations
tsdown's d.ts generation requires explicit types on exported consts
with computed initializers; tsc --noEmit does not run that check, so
the round-11 export slipped past local verification and broke Build
packages (and every downstream CI job that consumes the built dist).
* 🛟 fix: Round-12 Terminal-Path Recovery + Durable Steer Events
- Park queued steers before the stale-running reap deletes a crashed or
hung job in BOTH stores — the one terminal path with no controller
finalization; requires_action expiry parking refactored onto the same
snapshot/park helpers
- Enqueue instead of dropping when a steer fallback send is refused:
both the NO_ACTIVE_RUN branch and the settled-run rejection branch
now observe sendNow's false return
- Recover on the SSE reconnect-404 terminal path: convert local pending
steers to queued, claim parked steers via /chat/status, and write a
non-completed run-end signal so interrupt flags release without
auto-sending an unknown outcome
- Fall back to a positive parked-recovery TTL when completedTtl is 0
(SET EX 0 is invalid and silently killed recovery)
- Make on_steer_applied durable before publish: emitChunk gains a
durable option that awaits the chunk-log append (best-effort) ahead
of the transport publish; the default delta path stays fire-and-forget
* 🔐 fix: Round-13 Steer Authorization + Trusted File Refs
- Resolve client-supplied steer file refs against the DB owner-scoped
at enqueue and queue only DB-derived shapes (same filter as the
injection fetch, shared via refs.ts); any unresolved id fails loud
with 400 — spoofed type/filepath metadata can no longer be persisted
into assistant content or rendered in chat/share views
- Enforce agent authorization on /chat/steer against the ORIGINATING
run's job identity: the chat path's role gate (AGENTS:USE, with the
same non-agents-endpoint skip) plus the per-agent ACL check with the
capability bypass — revoked access mid-run can no longer inject;
cancel stays ownership-only (nothing model-bound)
- Mark steered uploads used after a successful enqueue (owner-scoped,
best-effort) so the upload-window TTL cannot reap a file the
persisted steer part references
- Consume the parked recovery copy after live delivery: converting
final/abort/error pendingSteers fires one owner-gated claim-on-read,
so dismissed chips can no longer resurrect on a later reload
* 🎙️ fix: Round-14 Composer-Context Fidelity + TTS and Queue-State Gaps
- Keep steer text out of generic assistant text extraction:
parseTextParts excludes STEER parts by default with an includeSteer
opt-in for the full-record surfaces (Meili indexing, aborted-response
persistence) — TTS callers no longer speak the user's own mid-run
words
- Mark queued uploads used at enqueue time via a minimal owner-scoped
POST /files/usage (fail-closed without a user; upload limiters do not
apply to a metadata touch), fired once wherever composer files enter
the queued state — the upload-window TTL can no longer reap a file
waiting out a long run or approval pause
- Carry quote chips and manual skill picks on queued items: captured
and consumed from the composer at queue/interrupt time exactly like
files, threaded through the drain and send-now overrides, and
restored by the queued row's Edit message
- Key an early-aborted FIRST turn's run-end signal to NEW_CONVO
(resolveRunEndTarget) so queued follow-ups stay visible on the
restored new-chat composer instead of parking under an optimistic
stream id the user never sees again
* 🧿 fix: Round-15 Gap Coverage + Consolidated Sweep (Share Leak, Abort Ids, Chip Hygiene)
- Run the resume steer-gap check for every still-active job: an empty
snapshot no longer skips the re-peek, and synthesis now keys on the
FRESH content view so an applied-in-gap steer that was never
snapshotted still re-surfaces (over-emission is benign — applied-id
dedupe, index-stable parts)
- Thread queued context through steer degradation: sendQueuedNow passes
the item's quotes/skills into submitSteer, and every fallback
(requeue or settled send) restores them instead of dropping to
text+files
- Stop shared links from leaking steer attachment refs: the share
snapshot now walks content — files-excluded shares strip steer-part
files entirely; files-included shares sanitize and share-route them
like top-level files (copy-on-write, non-steer content by reference)
- Seed pending-steer chips unconditionally on load/return so a steer
applied while away cannot linger as a stale chip beside its part
- Use the abort response's resolved job id: chips/drain-signal land
where the user actually is (NEW_CONVO for a new-held first turn,
consistent with resolveRunEndTarget) while the parked-copy claim hits
the resolved id instead of a no-op /chat/status/new
- Open steered documents like normal message files (FilePreviewDialog)
- Cap the applied-steer id set on the live path via a shared helper;
kept surviving run end deliberately (late-ACK race depends on it) and
fixed the atom comment that claimed otherwise
* 💡 fix: Un-light Steer Ribs When Their Node Is Replaced
Two stacked gaps kept a steer rib lit after scrolling away: the
pending→applied swap replaces the DOM node under the same id, which
produces no IntersectionObserver exit and — because the entry list
dedupes on (id, preview) — no entries change either, so the observer
kept watching a detached node; and the rail's mutation filter only
reacted to .message-render nodes, so steer-node swaps and removals
never triggered a refresh at all.
- reconcileObservedElements re-points the observer at replaced nodes
from the mutation-driven refresh regardless of entries identity,
dropping stale visibility until the fresh node reports (the observer
fires its initial intersection immediately, so a truly visible part
re-lights within a frame)
- The mutation filter now recognizes steer-render nodes alongside
message rows
* 🪪 fix: Round-16 Recovery Owner Fields + Context Stickiness + Share Labels
- Park resumed-run leftovers with the manager facade's metadata owner
fields: a bare job.userId is undefined on that shape, which made
every parked payload from a resumed HITL run unclaimable
- Keep a queued item's quotes/skills sticky through a successful steer
ACK: the pending chip carries them (client-only), reseeds preserve
them across reconnects, and every terminal conversion — local or
server-list, merged by steerId — restores them onto the queued item
- Convert resumeState.pendingSteers on the inactive status branch
(deduped against unrecoveredSteers) so steers observed in the
expired-pause-before-sweeper window convert instead of vanishing
until a later reload
- Label shared steer parts share-safely via the existing ShareContext:
a viewer's own name no longer appears on the sharer's steered
messages
* ✂️ fix: Carry Steer Context Through the Failed-Chip Edit Action
Retry and convert-to-queue already preserve a failed steer's carried
quotes/skills; Edit message dropped them on the way back to the
composer. It now restores them through the same context path.
* feat: encrypt tenant Langfuse secret in admin config
Add generic per-field secret encryption to the admin config layer: registered
secret paths (langfuse.secretKey) are encrypted with encryptV3 on write and a
non-secret fingerprint companion is stored. Admin config reads (base + per
principal) redact registered secrets so they are never returned; the fingerprint
is kept so the UI can show which key is configured.
The Langfuse fanout read path decrypts the tenant secret before export. Adds
secretKeyFingerprint to langfuseConfigSchema and tests for the encrypt/redact
policy.
* fix(api): secure admin config secret handling
* fix(api): preserve encrypted langfuse config secrets
* fix(api): couple config secret fingerprint deletion
* fix(api): read langfuse fanout collector url from env
* fix(api): display langfuse secret key hint
* fix(api): remove langfuse secret fingerprint breadcrumbs
* fix(api): use langfuse destination keys for tenant config
* fix(api): remove langfuse config compatibility fallbacks
* refactor(api): simplify langfuse secret helpers
* refactor(api): simplify langfuse config secret handling
---------
Co-authored-by: Ravi Kumar L <ravi.lazar@clickhouse.com>