diff --git a/api/server/controllers/agents/steer.js b/api/server/controllers/agents/steer.js
index baa86de010..2b6cb24a6c 100644
--- a/api/server/controllers/agents/steer.js
+++ b/api/server/controllers/agents/steer.js
@@ -1,4 +1,9 @@
-const { checkAccess, handleSteerRequest, handleSteerCancel } = require('@librechat/api');
+const {
+ checkAccess,
+ handleSteerRequest,
+ handleSteerCancel,
+ handleSteerArm,
+} = require('@librechat/api');
const { logger, ResourceCapabilityMap } = require('@librechat/data-schemas');
const {
Permissions,
@@ -107,5 +112,25 @@ const SteerCancelController = async (req, res) => {
}
};
+/**
+ * POST /api/agents/chat/steer/arm
+ *
+ * Escalates a still-queued steer to an interrupt in place (the durable item
+ * keeps its FIFO position). `armed: false` is not an error — the steer
+ * already injected, was cancelled, or the deployment cannot seal mid-stream.
+ * No agent-access check: arming injects nothing model-bound, so ownership
+ * checks suffice, exactly like cancel.
+ */
+const SteerArmController = async (req, res) => {
+ try {
+ const { status, body } = await handleSteerArm(req.user ?? {}, req.body ?? {});
+ return res.status(status).json(body);
+ } catch (error) {
+ logger.error('[SteerArmController] Failed to arm steer', error);
+ return res.status(500).json({ code: 'STEER_ARM_FAILED' });
+ }
+};
+
module.exports = SteerController;
module.exports.SteerCancelController = SteerCancelController;
+module.exports.SteerArmController = SteerArmController;
diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js
index 217c94203b..5382b7c457 100644
--- a/api/server/routes/agents/index.js
+++ b/api/server/routes/agents/index.js
@@ -474,6 +474,19 @@ router.post(
SteerController.SteerCancelController,
);
+/**
+ * @route POST /chat/steer/arm
+ * @desc Escalate a still-queued steer to an interrupt in place (no new
+ * model-bound content, so no PII/moderation pass — just the shared limiters)
+ * @access Private
+ */
+router.post(
+ '/chat/steer/arm',
+ configMiddleware,
+ ...steerLimiters,
+ SteerController.SteerArmController,
+);
+
router.use('/', v1);
const chatRouter = express.Router();
diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx
index 7e84c7001f..b71db39971 100644
--- a/client/src/components/Chat/Input/InFlightSteers.tsx
+++ b/client/src/components/Chat/Input/InFlightSteers.tsx
@@ -1,5 +1,5 @@
import { memo, useId, useRef, useMemo, useState, useEffect, useCallback } from 'react';
-import { useSetAtom, useAtomValue } from 'jotai';
+import { useSetAtom } from 'jotai';
import { useToastContext } from '@librechat/client';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import { X, Zap, ZapOff, Clock, Pencil, ChevronUp, ChevronDown } from 'lucide-react';
@@ -9,11 +9,12 @@ import type { PendingSteer } from '~/store/families';
import type { MenuEntry } from './SteerMenu';
import { RowMenu, useDefaultToggleEntry, useInterruptToggleEntry } from './SteerMenu';
import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog';
-import { steerOverlayHeightFamily, escalatingSteerFamily } from '~/store/steer';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import { useSteerCancel, useSteerReclaim, useLocalize } from '~/hooks';
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
+import { steerOverlayHeightFamily } from '~/store/steer';
+import { useArmSteerMutation } from '~/data-provider';
import { carriedSteerContext, cn } from '~/utils';
import store from '~/store';
@@ -130,27 +131,17 @@ const InFlightSteer = memo(function InFlightSteer({
[conversationId],
);
- /** Fresh read at resubmit time: an interrupt armed while this escalation's
- * reclaim was in flight (another bubble, a queued row, the composer chord)
- * means resubmitting would break the one-interrupt invariant. */
- const hasUnresolvedInterrupt = useRecoilCallback(
- ({ snapshot }) =>
- () =>
- snapshot
- .getLoadable(store.pendingSteersByConvoId(conversationId))
- .getValue()
- .some((item) => item.preempt === true && item.status !== 'failed'),
+ /** Relabels the chip in place once the server confirms the durable arm —
+ * same steerId, same position, only the `preempt` flag flips. */
+ const markSteerPreempt = useRecoilCallback(
+ ({ set }) =>
+ (steerId: string) =>
+ set(store.pendingSteersByConvoId(conversationId), (prev) =>
+ prev.map((item) => (item.steerId === steerId ? { ...item, preempt: true } : item)),
+ ),
[conversationId],
);
- const setEscalating = useSetAtom(escalatingSteerFamily(conversationId));
-
- /** Latest controls for the escalation's async continuation: the `.then`
- * closure otherwise holds the render's stale `steering`, blind to a run
- * that paused (approval, answer mode) while the reclaim was in flight. */
- const steeringRef = useRef(steering);
- useEffect(() => {
- steeringRef.current = steering;
- });
+ const { mutateAsync: armSteer } = useArmSteerMutation();
/**
* Takes the steer back off the server queue so its words can be re-homed.
@@ -235,54 +226,40 @@ const InFlightSteer = memo(function InFlightSteer({
key: 'interrupt',
label: localize('com_ui_interrupt_steer_now'),
icon: ,
- /* `!duringRunActive` also covers answer mode (`ask_user_question`),
- * where `pausedOnApproval` stays false but a resubmit would bounce
- * off RUN_PAUSED after the reclaim already gave up the boundary. */
+ /* UX gate only — correctness lives in the server's atomic arm,
+ * which is fenced to this generation and refuses once the item
+ * left the queue. `!duringRunActive` covers answer mode, where
+ * `pausedOnApproval` stays false. */
disabled: interruptPending || steering.pausedOnApproval || !steering.duringRunActive,
+ /* One atomic server op: `preempt` flips on the EXISTING queued
+ * item, so its FIFO position, id, and timestamp survive and there
+ * is no reclaim window to race. Every "too late" interleaving
+ * (drained, cancelled, run ended or replaced) is the same honest
+ * `armed: false`, and the chip is only relabelled on a confirmed
+ * durable arm. */
onClick: () => {
- setEscalating(true);
- void reclaim()
- .then((reclaimed) => {
- if (!reclaimed) {
+ void armSteer({ conversationId, steerId: steer.steerId }).then(
+ (response) => {
+ if (response.armed === true) {
+ markSteerPreempt(steer.steerId);
return;
}
- if (hasSettled(steer.steerId)) {
- showToast({
- message: localize('com_ui_steer_run_ended_queued'),
- status: 'info',
- });
- return;
- }
- /* Live controls, not the render's: the run can pause (or an
- * interrupt can arm) while the reclaim is in flight, and the
- * boundary slot is already surrendered — re-home the words
- * rather than resubmit into a rejection. */
- const live = steeringRef.current;
- if (!live.duringRunActive || live.pausedOnApproval) {
- live.queueReclaimedSteer(steer);
- showToast({
- message: localize('com_ui_steer_run_paused_queued'),
- status: 'info',
- });
- return;
- }
- if (hasUnresolvedInterrupt()) {
- live.queueReclaimedSteer(steer);
- showToast({
- message: localize('com_ui_steer_interrupt_busy_queued'),
- status: 'info',
- });
- return;
- }
- live.retrySteer(
- steer.steerId,
- steer.text,
- steer.files,
- carriedSteerContext(steer),
- { preempt: true },
- );
- })
- .finally(() => setEscalating(false));
+ showToast({
+ message: localize(
+ response.code === 'PREEMPT_UNSUPPORTED'
+ ? 'com_ui_steer_preempt_unsupported'
+ : 'com_ui_steer_already_applied',
+ ),
+ status: 'info',
+ });
+ },
+ () => {
+ showToast({
+ message: localize('com_ui_steer_arm_failed'),
+ status: 'error',
+ });
+ },
+ );
},
} satisfies MenuEntry,
]),
@@ -467,13 +444,10 @@ const InFlightSteers = memo(function InFlightSteers({
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
const inFlight = useMemo(() => steers.filter((steer) => steer.status !== 'failed'), [steers]);
/** Mirrors `PendingSteerChips`: while one interrupt is unresolved, every
- * other escalation control disables rather than racing the same seal. The
- * escalating flag covers the reclaim window, before a preempt chip exists
- * for the chip-derived check to see. */
- const escalating = useAtomValue(escalatingSteerFamily(conversationId));
+ * other escalation control disables rather than arming a second seal. */
const interruptPending = useMemo(
- () => escalating || inFlight.some((steer) => steer.preempt === true),
- [escalating, inFlight],
+ () => inFlight.some((steer) => steer.preempt === true),
+ [inFlight],
);
const setOverlayHeight = useSetAtom(steerOverlayHeightFamily(conversationId));
diff --git a/client/src/components/Chat/Input/PendingSteerChips.tsx b/client/src/components/Chat/Input/PendingSteerChips.tsx
index 2eed34a3cd..0f7456e4c7 100644
--- a/client/src/components/Chat/Input/PendingSteerChips.tsx
+++ b/client/src/components/Chat/Input/PendingSteerChips.tsx
@@ -1,5 +1,4 @@
import { memo, useMemo } from 'react';
-import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { X, Zap, Send, Clock, Pencil, Trash2, ZapOff, Paperclip, RotateCcw } from 'lucide-react';
@@ -16,7 +15,6 @@ import {
useInterruptChordHint,
useInterruptToggleEntry,
} from './SteerMenu';
-import { escalatingSteerFamily } from '~/store/steer';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@@ -291,13 +289,10 @@ function PendingSteerChips({
const queued = useRecoilValue(store.queuedMessagesByConvoId(steering.queueKey));
const failedSteers = useMemo(() => steers.filter((steer) => steer.status === 'failed'), [steers]);
/** Only one interrupt can be in flight: a second preempt while one is
- * unresolved would race the same seal, so escalation buttons disable. The
- * escalating flag covers a bubble escalation's reclaim window, before its
- * preempt chip exists for the chip-derived check to see. */
- const escalating = useAtomValue(escalatingSteerFamily(conversationId));
+ * unresolved would arm a second seal, so escalation buttons disable. */
const interruptPending = useMemo(
- () => escalating || steers.some((steer) => steer.preempt === true && steer.status !== 'failed'),
- [escalating, steers],
+ () => steers.some((steer) => steer.preempt === true && steer.status !== 'failed'),
+ [steers],
);
if (failedSteers.length === 0 && queued.length === 0) {
diff --git a/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx
index 7663f956c6..908ee2d0df 100644
--- a/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx
+++ b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx
@@ -1,10 +1,10 @@
import React from 'react';
+import { RecoilRoot } from 'recoil';
import { getDefaultStore } from 'jotai';
-import { RecoilRoot, useSetRecoilState } from 'recoil';
-import { render, screen, within, fireEvent, act } from '@testing-library/react';
+import { render, screen, fireEvent, act } from '@testing-library/react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import type { PendingSteer } from '~/store/families';
-import { steerOverlayHeightFamily, escalatingSteerFamily } from '~/store/steer';
+import { steerOverlayHeightFamily } from '~/store/steer';
import InFlightSteers from '../InFlightSteers';
import store from '~/store';
@@ -13,6 +13,7 @@ const mockShowToast = jest.fn();
const mockQueueReclaimedSteer = jest.fn();
const mockRemoveSteer = jest.fn();
const mockRetrySteer = jest.fn();
+const mockArmMutateAsync = jest.fn();
const mockSetDefaultAction = jest.fn();
const mockRestoreToComposer = jest.fn();
@@ -28,6 +29,7 @@ jest.mock('@librechat/client', () => ({
jest.mock('~/data-provider', () => ({
useCancelSteerMutation: () => ({ mutateAsync: mockCancelMutateAsync }),
+ useArmSteerMutation: () => ({ mutateAsync: mockArmMutateAsync }),
}));
jest.mock('~/components/Chat/Input/Files/FileContainer', () => ({
@@ -76,14 +78,6 @@ const steeringStub = (defaultAction: 'steer' | 'queue' = 'steer', duringRunActiv
queueReclaimedSteer: mockQueueReclaimedSteer,
}) as unknown as SteeringControls;
-/** Captured live setter so a test can arm an interrupt mid-reclaim, the way
- * the composer chord or a queued row would from outside this component. */
-let setLiveSteers: ((steers: PendingSteer[]) => void) | null = null;
-function CaptureSteersSetter() {
- setLiveSteers = useSetRecoilState(store.pendingSteersByConvoId(CONVO_ID));
- return null;
-}
-
type RenderOptions = {
enableUserMsgMarkdown?: boolean;
appliedSteerIds?: string[];
@@ -106,7 +100,6 @@ function steersElement(steers: PendingSteer[], options?: RenderOptions) {
}
}}
>
-
{
});
/**
- * The bubble's "Interrupt now" escalation: reclaim the waiting steer off the
- * server queue, then resubmit it as a preempt via `retrySteer` — only a
- * `reclaimed` outcome proves the words never entered the run, so anything
- * else must not resubmit (the text would land twice).
+ * The bubble's "Interrupt now" escalation is ONE atomic server op: `preempt`
+ * flips on the existing queued item, so its FIFO position, id, and timestamp
+ * survive and no reclaim window exists to race. `armed: false` is the honest
+ * answer for every "too late" interleaving (drained, cancelled, run ended or
+ * replaced) and for a deployment that cannot seal mid-stream.
*/
describe('InFlightSteers — interrupt-now escalation', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCancelMutateAsync.mockResolvedValue({ removed: true });
+ mockArmMutateAsync.mockResolvedValue({ armed: true });
mockRestoreToComposer.mockReturnValue(true);
});
- it('escalates a waiting steer to an interrupt once reclaimed', async () => {
+ it("arms the interrupt in place, keeping the steer's id and position", async () => {
renderSteers([{ steerId: 's1', text: 'hold on', status: 'pending', createdAt: 1 }]);
await clickMenuItem('com_ui_interrupt_steer_now');
- expect(mockCancelMutateAsync).toHaveBeenCalled();
- expect(mockRetrySteer).toHaveBeenCalledWith('s1', 'hold on', undefined, {}, { preempt: true });
+ expect(mockArmMutateAsync).toHaveBeenCalledWith({
+ conversationId: CONVO_ID,
+ steerId: 's1',
+ });
+ /** No cancel, no resubmission: the durable item never left the queue. */
+ expect(mockCancelMutateAsync).not.toHaveBeenCalled();
+ expect(mockRetrySteer).not.toHaveBeenCalled();
+ expect(screen.getByText('hold on')).toBeInTheDocument();
+
+ /** The chip relabelled in place: an interrupting steer offers no further
+ * escalation, so the entry is gone on reopen. */
+ fireEvent.click(screen.getByLabelText('com_ui_more_options'));
+ expect(await screen.findByText('com_ui_steer_cancel')).toBeInTheDocument();
+ expect(screen.queryByText('com_ui_interrupt_steer_now')).toBeNull();
});
it('does not offer escalation on a steer that is already interrupting', async () => {
@@ -626,8 +633,21 @@ describe('InFlightSteers — interrupt-now escalation', () => {
expect(screen.queryByText('com_ui_interrupt_steer_now')).toBeNull();
});
- it('does not resubmit when the steer already entered the run', async () => {
- mockCancelMutateAsync.mockResolvedValue({ removed: false });
+ it('keeps the chip an ordinary steer when the deployment cannot seal', async () => {
+ mockArmMutateAsync.mockResolvedValue({ armed: false, code: 'PREEMPT_UNSUPPORTED' });
+ renderSteers([{ steerId: 's1', text: 'no seal here', status: 'pending', createdAt: 1 }]);
+ await clickMenuItem('com_ui_interrupt_steer_now');
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'com_ui_steer_preempt_unsupported' }),
+ );
+ /** Still an ordinary steer: escalation stays offered. */
+ fireEvent.click(screen.getByLabelText('com_ui_more_options'));
+ expect(await screen.findByText('com_ui_interrupt_steer_now')).toBeInTheDocument();
+ });
+
+ it('defers to the events when the steer already left the queue', async () => {
+ mockArmMutateAsync.mockResolvedValue({ armed: false });
renderSteers([{ steerId: 's1', text: 'too late', status: 'pending', createdAt: 1 }]);
await clickMenuItem('com_ui_interrupt_steer_now');
@@ -637,18 +657,15 @@ describe('InFlightSteers — interrupt-now escalation', () => {
);
});
- it('leaves the words queued when the run ended mid-reclaim', async () => {
- // A terminal conversion already re-homed the chip; resubmitting would
- // duplicate the text, so escalation stops at an informational toast.
- renderSteers([{ steerId: 's1', text: 'run over', status: 'pending', createdAt: 1 }], {
- appliedSteerIds: ['s1'],
- });
+ it('reports an arm failure without touching the steer', async () => {
+ mockArmMutateAsync.mockRejectedValue(new Error('network'));
+ renderSteers([{ steerId: 's1', text: 'still queued', status: 'pending', createdAt: 1 }]);
await clickMenuItem('com_ui_interrupt_steer_now');
- expect(mockRetrySteer).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'com_ui_steer_run_ended_queued' }),
+ expect.objectContaining({ message: 'com_ui_steer_arm_failed', status: 'error' }),
);
+ expect(screen.getByText('still queued')).toBeInTheDocument();
});
it('disables escalation on every bubble while an interrupt is unresolved', async () => {
@@ -663,125 +680,23 @@ describe('InFlightSteers — interrupt-now escalation', () => {
await act(async () => {
fireEvent.click(item);
});
- expect(mockRetrySteer).not.toHaveBeenCalled();
- expect(mockCancelMutateAsync).not.toHaveBeenCalled();
+ expect(mockArmMutateAsync).not.toHaveBeenCalled();
});
});
/**
- * Codex round 1 on the escalation PR: the single-interrupt invariant has a
- * window between clicking "Interrupt now" and the reclaim resolving, where no
- * preempt chip exists for the chip-derived gate to see. The shared escalating
- * flag covers the window, and a fresh recheck before resubmitting catches an
- * interrupt armed elsewhere (composer chord, queued row) meanwhile.
- */
-describe('InFlightSteers — escalation races', () => {
- beforeEach(() => {
- /** Full reset (not just clear): a leaked `mockImplementationOnce` from a
- * failed sibling would otherwise hijack this test's first reclaim. */
- jest.clearAllMocks();
- mockCancelMutateAsync.mockReset();
- mockCancelMutateAsync.mockResolvedValue({ removed: true });
- mockRestoreToComposer.mockReturnValue(true);
- act(() => {
- getDefaultStore().set(escalatingSteerFamily(CONVO_ID), false);
- });
- });
-
- /** Every bubble's menu content is mounted (hidden) up front, so item lookups
- * must scope to the menu the clicked button controls. */
- const menuFor = (button: HTMLElement) =>
- document.getElementById(button.getAttribute('aria-controls') ?? '');
-
- it('locks out a second escalation while the first reclaim is in flight', async () => {
- let resolveReclaim: (value: { removed: boolean }) => void = () => {};
- mockCancelMutateAsync.mockImplementationOnce(
- () =>
- new Promise((resolve) => {
- resolveReclaim = resolve;
- }),
- );
- renderSteers([
- { steerId: 's1', text: 'first', status: 'pending', createdAt: 1 },
- { steerId: 's2', text: 'second', status: 'pending', createdAt: 2 },
- ]);
- const menus = screen.getAllByLabelText('com_ui_more_options');
- fireEvent.click(menus[0]);
- const firstMenu = menuFor(menus[0]);
- fireEvent.click(
- await within(firstMenu as HTMLElement).findByText('com_ui_interrupt_steer_now'),
- );
-
- fireEvent.click(menus[1]);
- const secondMenu = menuFor(menus[1]);
- const secondItem = await within(secondMenu as HTMLElement).findByText(
- 'com_ui_interrupt_steer_now',
- );
- expect(secondItem.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true');
- await act(async () => {
- fireEvent.click(secondItem);
- });
-
- await act(async () => {
- resolveReclaim({ removed: true });
- });
- expect(mockCancelMutateAsync).toHaveBeenCalledTimes(1);
- expect(mockRetrySteer).toHaveBeenCalledTimes(1);
- expect(mockRetrySteer).toHaveBeenCalledWith('s1', 'first', undefined, {}, { preempt: true });
- });
-
- it('requeues instead of resubmitting when an interrupt appears mid-reclaim', async () => {
- let resolveReclaim: (value: { removed: boolean }) => void = () => {};
- mockCancelMutateAsync.mockImplementationOnce(
- () =>
- new Promise((resolve) => {
- resolveReclaim = resolve;
- }),
- );
- renderSteers([{ steerId: 's1', text: 'late to the seal', status: 'pending', createdAt: 1 }]);
- fireEvent.click(screen.getByLabelText('com_ui_more_options'));
- fireEvent.click(await screen.findByText('com_ui_interrupt_steer_now'));
-
- act(() => {
- setLiveSteers?.([
- { steerId: 's1', text: 'late to the seal', status: 'pending', createdAt: 1 },
- {
- steerId: 'p2',
- text: 'composer interrupt',
- status: 'sending',
- createdAt: 2,
- preempt: true,
- },
- ]);
- });
- await act(async () => {
- resolveReclaim({ removed: true });
- });
-
- expect(mockRetrySteer).not.toHaveBeenCalled();
- expect(mockQueueReclaimedSteer).toHaveBeenCalledWith(
- expect.objectContaining({ steerId: 's1' }),
- );
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'com_ui_steer_interrupt_busy_queued' }),
- );
- });
-});
-
-/**
- * Codex round 2: answer mode (`ask_user_question`) sets `duringRunActive`
- * false while `pausedOnApproval` stays false (it only detects approval-bearing
- * tool calls). Escalating there would cancel a healthy waiting steer and then
- * bounce off RUN_PAUSED, so the entry disables like the queued-row control.
+ * Answer mode (`ask_user_question`) sets `duringRunActive` false while
+ * `pausedOnApproval` stays false (it only detects approval-bearing tool
+ * calls). The entry disables there as a UX gate; the server-side arm is the
+ * correctness backstop for states the client cannot see.
*/
describe('InFlightSteers — escalation while the run cannot accept a steer', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockCancelMutateAsync.mockReset();
- mockCancelMutateAsync.mockResolvedValue({ removed: true });
+ mockArmMutateAsync.mockResolvedValue({ armed: true });
});
- it('disables escalation in answer mode instead of cancelling a healthy steer', async () => {
+ it('disables escalation in answer mode', async () => {
renderSteers([{ steerId: 's1', text: 'waiting', status: 'pending', createdAt: 1 }], {
duringRunActive: false,
});
@@ -792,53 +707,6 @@ describe('InFlightSteers — escalation while the run cannot accept a steer', ()
await act(async () => {
fireEvent.click(item);
});
- expect(mockCancelMutateAsync).not.toHaveBeenCalled();
- expect(mockRetrySteer).not.toHaveBeenCalled();
- });
-});
-
-/**
- * Codex round 3: the entry-time disable cannot see a run that pauses AFTER
- * the click, while the reclaim round-trip is in flight. The continuation
- * reads the LIVE steering controls (latest-ref) and re-homes the words
- * instead of resubmitting into a RUN_PAUSED rejection.
- */
-describe('InFlightSteers — run pauses mid-reclaim', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- mockCancelMutateAsync.mockReset();
- mockCancelMutateAsync.mockResolvedValue({ removed: true });
- });
-
- it('re-homes without resubmitting when the run pauses mid-reclaim', async () => {
- let resolveReclaim: (value: { removed: boolean }) => void = () => {};
- mockCancelMutateAsync.mockImplementationOnce(
- () =>
- new Promise((resolve) => {
- resolveReclaim = resolve;
- }),
- );
- const steer: PendingSteer = {
- steerId: 's1',
- text: 'mid-pause',
- status: 'pending',
- createdAt: 1,
- };
- const view = renderSteers([steer]);
- fireEvent.click(screen.getByLabelText('com_ui_more_options'));
- fireEvent.click(await screen.findByText('com_ui_interrupt_steer_now'));
-
- view.rerender(steersElement([steer], { duringRunActive: false }));
- await act(async () => {
- resolveReclaim({ removed: true });
- });
-
- expect(mockRetrySteer).not.toHaveBeenCalled();
- expect(mockQueueReclaimedSteer).toHaveBeenCalledWith(
- expect.objectContaining({ steerId: 's1' }),
- );
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'com_ui_steer_run_paused_queued' }),
- );
+ expect(mockArmMutateAsync).not.toHaveBeenCalled();
});
});
diff --git a/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx b/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx
index b0a963151d..fc143e4874 100644
--- a/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx
+++ b/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx
@@ -1,10 +1,8 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
-import { getDefaultStore } from 'jotai';
-import { render, screen, fireEvent, act } from '@testing-library/react';
+import { render, screen, fireEvent } from '@testing-library/react';
import type { PendingSteer, QueuedMessage } from '~/store/families';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
-import { escalatingSteerFamily } from '~/store/steer';
import PendingSteerChips from '../PendingSteerChips';
import store from '~/store';
@@ -150,21 +148,6 @@ describe('PendingSteerChips — queued interrupt-now', () => {
expect(mockSendQueuedNow).not.toHaveBeenCalled();
});
- it('disables escalation while a bubble escalation is mid-reclaim', () => {
- const jotai = getDefaultStore();
- act(() => {
- jotai.set(escalatingSteerFamily(CONVO_ID), true);
- });
- try {
- renderChips([queuedMessage], { steering: liveRun });
- expect(screen.getByTestId('queued-interrupt-now')).toBeDisabled();
- } finally {
- act(() => {
- jotai.set(escalatingSteerFamily(CONVO_ID), false);
- });
- }
- });
-
it('offers the always-interrupt preference in the row menu and flips it', async () => {
renderChips([queuedMessage], { steering: liveRun });
fireEvent.click(screen.getByLabelText('com_ui_more_options'));
diff --git a/client/src/data-provider/SSE/mutations.ts b/client/src/data-provider/SSE/mutations.ts
index 0f6b3d0afd..5a2386d5cf 100644
--- a/client/src/data-provider/SSE/mutations.ts
+++ b/client/src/data-provider/SSE/mutations.ts
@@ -217,3 +217,32 @@ export function useCancelSteerMutation() {
mutationFn: cancelSteerMessage,
});
}
+
+export interface ArmSteerParams {
+ conversationId: string;
+ steerId: string;
+}
+
+export interface ArmSteerResponse {
+ armed?: boolean;
+ code?: string;
+}
+
+/**
+ * Escalates a still-queued steer to an interrupt IN PLACE: the server flips
+ * `preempt` on the existing item, so its FIFO position, id, and timestamp all
+ * survive. `armed: false` is not an error — the steer already injected, was
+ * cancelled, or the deployment cannot seal mid-stream (`PREEMPT_UNSUPPORTED`).
+ */
+export const armSteerMessage = async (params: ArmSteerParams): Promise => {
+ return request.post(
+ `${apiBaseUrl()}/api/agents/chat/steer/arm`,
+ params,
+ ) as Promise;
+};
+
+export function useArmSteerMutation() {
+ return useMutation({
+ mutationFn: armSteerMessage,
+ });
+}
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index c85224aca8..f4603ecb12 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1917,19 +1917,19 @@
"com_ui_status_prefix": "Status:",
"com_ui_steer": "Steer",
"com_ui_steer_already_applied": "That steering message already reached the agent, so it was left in the response",
+ "com_ui_steer_arm_failed": "Couldn't arm the interrupt, so that steering message still lands at the next tool step",
"com_ui_steer_cancel": "Cancel steering message",
"com_ui_steer_cancel_failed": "Could not cancel the steering message — it may still reach the agent",
"com_ui_steer_edit_queued": "Your composer already has a draft, so that steering message was queued for after the response instead",
"com_ui_steer_failed": "Steering failed",
"com_ui_steer_in_flight": "Steering",
"com_ui_steer_in_flight_preempt": "Interrupting",
- "com_ui_steer_interrupt_busy_queued": "Another interrupt is already in flight, so that steering message was queued for after the response instead",
"com_ui_steer_interrupts_default": "Steering interrupts generation",
"com_ui_steer_interrupts_default_info": "When on, Enter stops the response at the next safe point instead of waiting for the agent's next tool step. Either way the partial answer is kept and the response continues.",
"com_ui_steer_paused_queued": "The agent is waiting for your review — your message was queued instead",
+ "com_ui_steer_preempt_unsupported": "This deployment can't interrupt mid-response, so that steering message stays queued for the next tool step",
"com_ui_steer_retry": "Retry steering",
"com_ui_steer_run_ended_queued": "The response ended, so that steering message is queued as a follow-up",
- "com_ui_steer_run_paused_queued": "The response paused before the interrupt could arm, so that steering message is queued as a follow-up",
"com_ui_steer_send": "Steer the current response",
"com_ui_steered_info": "You added this message while the response was generating, so it was inserted into the response at this point.",
"com_ui_stop": "Stop",
diff --git a/client/src/store/steer.ts b/client/src/store/steer.ts
index d035cc7473..b3fd63c451 100644
--- a/client/src/store/steer.ts
+++ b/client/src/store/steer.ts
@@ -11,11 +11,3 @@ import { atomFamily } from 'jotai/utils';
* each holds a single number per visited conversation.
*/
export const steerOverlayHeightFamily = atomFamily((_conversationId: string) => atom(0));
-
-/**
- * Set while an "Interrupt now" escalation is awaiting its reclaim round-trip.
- * In that window no preempt chip exists yet, so the chip-derived
- * single-interrupt gate cannot see the escalation; every other escalation
- * control disables on this instead of racing the same seal.
- */
-export const escalatingSteerFamily = atomFamily((_conversationId: string) => atom(false));
diff --git a/packages/api/src/agents/steering/__tests__/request.spec.ts b/packages/api/src/agents/steering/__tests__/request.spec.ts
index 57301b991a..380291f499 100644
--- a/packages/api/src/agents/steering/__tests__/request.spec.ts
+++ b/packages/api/src/agents/steering/__tests__/request.spec.ts
@@ -1,11 +1,11 @@
import type { IMongoFile } from '@librechat/data-schemas';
import { InMemoryEventTransport } from '~/stream/implementations/InMemoryEventTransport';
import { buildPendingAction, buildToolApprovalPayload } from '~/agents/hitl/policy';
+import { handleSteerRequest, handleSteerCancel, handleSteerArm } from '../request';
import { InMemoryJobStore } from '~/stream/implementations/InMemoryJobStore';
import { isSteeringSupported, isSteerPreemptSupported } from '../runtime';
import { STEER_QUEUE_MAX_DEPTH } from '~/stream/interfaces/IJobStore';
import { GenerationJobManager } from '~/stream/GenerationJobManager';
-import { handleSteerRequest, handleSteerCancel } from '../request';
jest.mock('../runtime', () => ({
...jest.requireActual('../runtime'),
@@ -733,3 +733,108 @@ describe('preempt flag on the steer request', () => {
expect(GenerationJobManager.isPreemptRequested(streamId)).toBe(true);
});
});
+
+/**
+ * Escalation of a waiting steer is ONE atomic in-place flag flip — the item
+ * keeps its FIFO position, id, and timestamp, so the whole queue still drains
+ * in the user's instruction order at the seal, and no reclaim window exists.
+ */
+describe('handleSteerArm (real in-memory job manager)', () => {
+ function createCapableJob(streamId: string) {
+ return GenerationJobManager.createJob(streamId, user.id, undefined, {
+ initialMetadata: { preemptCapable: true },
+ });
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsSupported.mockReturnValue(true);
+ mockIsPreemptSupported.mockReturnValue(true);
+ GenerationJobManager.configure({
+ jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }),
+ eventTransport: new InMemoryEventTransport(),
+ isRedis: false,
+ cleanupOnComplete: false,
+ });
+ GenerationJobManager.initialize();
+ });
+
+ afterEach(async () => {
+ await GenerationJobManager.destroy();
+ });
+
+ it('400s on invalid input', async () => {
+ expect((await handleSteerArm(user, { steerId: 's1' })).status).toBe(400);
+ const badId = await handleSteerArm(user, { conversationId: 'c1', steerId: '' });
+ expect(badId.status).toBe(400);
+ expect(badId.body.code).toBe('INVALID_STEER_ID');
+ });
+
+ it('arms a queued steer in place: same item, same position, now preempting', async () => {
+ const streamId = 'arm-in-place';
+ await createCapableJob(streamId);
+ const first = await handleSteerRequest(user, { conversationId: streamId, text: 'first' });
+ const second = await handleSteerRequest(user, { conversationId: streamId, text: 'second' });
+ const firstId = first.body.steerId as string;
+
+ const armed = await handleSteerArm(user, { conversationId: streamId, steerId: firstId });
+ expect(armed).toEqual({ status: 200, body: { armed: true } });
+
+ /** FIFO preserved: the escalated steer still drains FIRST at the seal. */
+ const queue = await GenerationJobManager.steering.peek(streamId);
+ expect(queue.map((item) => item.steerId)).toEqual([firstId, second.body.steerId]);
+ expect(queue[0].preempt).toBe(true);
+ expect(queue[1].preempt).toBeUndefined();
+ expect(GenerationJobManager.isPreemptRequested(streamId)).toBe(true);
+ });
+
+ it('refuses to relabel when the owner cannot seal', async () => {
+ const streamId = 'arm-incapable';
+ await GenerationJobManager.createJob(streamId, user.id);
+ const posted = await handleSteerRequest(user, { conversationId: streamId, text: 'plain' });
+ const steerId = posted.body.steerId as string;
+
+ const result = await handleSteerArm(user, { conversationId: streamId, steerId });
+ expect(result).toEqual({ status: 200, body: { armed: false, code: 'PREEMPT_UNSUPPORTED' } });
+ expect((await GenerationJobManager.steering.peek(streamId))[0].preempt).toBeUndefined();
+ expect(GenerationJobManager.isPreemptRequested(streamId)).toBe(false);
+ });
+
+ it('reports armed:false when the steer already left the queue', async () => {
+ const streamId = 'arm-too-late';
+ await createCapableJob(streamId);
+ const posted = await handleSteerRequest(user, { conversationId: streamId, text: 'gone soon' });
+ const steerId = posted.body.steerId as string;
+ await GenerationJobManager.steering.cancel(streamId, steerId);
+
+ const result = await handleSteerArm(user, { conversationId: streamId, steerId });
+ expect(result).toEqual({ status: 200, body: { armed: false } });
+ });
+
+ it('treats a missing job as a lost race, not an error', async () => {
+ const result = await handleSteerArm(user, { conversationId: 'gone', steerId: 's1' });
+ expect(result).toEqual({ status: 200, body: { armed: false } });
+ });
+
+ it("403s another user's run", async () => {
+ const streamId = 'arm-foreign';
+ await GenerationJobManager.createJob(streamId, 'someone-else');
+ const result = await handleSteerArm(user, { conversationId: streamId, steerId: 'x' });
+ expect(result.status).toBe(403);
+ });
+
+ it("never arms another generation's steer", async () => {
+ const streamId = 'arm-stale-generation';
+ await createCapableJob(streamId);
+ const posted = await handleSteerRequest(user, { conversationId: streamId, text: 'target' });
+ const live = await GenerationJobManager.getJob(streamId);
+
+ const armed = await GenerationJobManager.steering.arm(
+ streamId,
+ posted.body.steerId as string,
+ (live?.createdAt as number) + 999,
+ );
+ expect(armed).toBe(false);
+ expect((await GenerationJobManager.steering.peek(streamId))[0].preempt).toBeUndefined();
+ });
+});
diff --git a/packages/api/src/agents/steering/index.ts b/packages/api/src/agents/steering/index.ts
index 01c8a68fcb..35eb3fbd7f 100644
--- a/packages/api/src/agents/steering/index.ts
+++ b/packages/api/src/agents/steering/index.ts
@@ -9,6 +9,7 @@ export type { SteerDrainHookOptions, SteerMediaResult } from './runtime';
export {
handleSteerRequest,
handleSteerCancel,
+ handleSteerArm,
getSteerMaxLength,
STEER_MAX_FILES,
} from './request';
diff --git a/packages/api/src/agents/steering/request.ts b/packages/api/src/agents/steering/request.ts
index b37b1acef4..f9c0374a88 100644
--- a/packages/api/src/agents/steering/request.ts
+++ b/packages/api/src/agents/steering/request.ts
@@ -456,3 +456,68 @@ export async function handleSteerCancel(
clearTimeout(settleTimer);
return { status: 200, body: { removed } };
}
+
+/**
+ * Escalates a still-queued steer to an interrupt IN PLACE — one atomic flag
+ * flip on the existing item, so its FIFO position, id, and timestamp all
+ * survive and no reclaim window ever exists. `armed: false` is not an error:
+ * the steer already injected, was cancelled, or belongs to a run that ended
+ * (the client defers to the events it will receive). Mirrors the steer POST's
+ * preempt contract exactly: the durable flag is gated on the OWNER's recorded
+ * capability, the store op is fenced to the validated generation, and the
+ * volatile arm publish is fire-and-forget because the durable flag is the
+ * truth `rearmQueuedPreempts` trusts on resume and handover.
+ */
+export async function handleSteerArm(
+ user: SteerRequestUser,
+ body: SteerCancelBody,
+): Promise {
+ const conversationId = body.conversationId;
+ if (typeof conversationId !== 'string' || !conversationId || conversationId === 'new') {
+ return { status: 400, body: { code: 'INVALID_CONVERSATION' } };
+ }
+ if (typeof body.steerId !== 'string' || body.steerId.length === 0) {
+ return { status: 400, body: { code: 'INVALID_STEER_ID' } };
+ }
+
+ const streamId = conversationId;
+ const job = await GenerationJobManager.getJob(streamId);
+ if (!job) {
+ return { status: 200, body: { armed: false } };
+ }
+ if (job.metadata?.userId && job.metadata.userId !== user.id) {
+ logger.warn(`[handleSteerArm] Unauthorized arm attempt for ${streamId} by ${user.id}`);
+ return { status: 403, body: { code: 'UNAUTHORIZED' } };
+ }
+ if (hasTenantMismatch(job.metadata, user)) {
+ return { status: 403, body: { code: 'UNAUTHORIZED' } };
+ }
+ if (job.metadata?.preemptCapable !== true) {
+ /** Same honesty rule as the POST's echo: an owner that cannot seal must
+ * not have its steer relabelled "interrupting". The steer stays queued
+ * for the next tool boundary, which is the documented degradation. */
+ return { status: 200, body: { armed: false, code: 'PREEMPT_UNSUPPORTED' } };
+ }
+
+ const armed = await GenerationJobManager.steering.arm(streamId, body.steerId, job.createdAt);
+ if (!armed) {
+ return { status: 200, body: { armed: false } };
+ }
+ /** NOT awaited, exactly like the POST: the durable flag is already the
+ * truth, a lost publish degrades to the tool-boundary fallback, and
+ * resume/handover re-arm from the queue. */
+ void GenerationJobManager.requestPreempt(streamId, body.steerId, job.createdAt).then(
+ (confirmed) => {
+ if (!confirmed) {
+ logger.warn(
+ `[handleSteerArm] Preempt arm not confirmed for ${streamId} steer=${body.steerId}; ` +
+ 'the steer remains queued and will inject at the next boundary',
+ );
+ }
+ },
+ (error) => {
+ logger.error(`[handleSteerArm] Preempt arm publish failed for ${streamId}:`, error);
+ },
+ );
+ return { status: 200, body: { armed: true } };
+}
diff --git a/packages/api/src/stream/SteeringLifecycle.ts b/packages/api/src/stream/SteeringLifecycle.ts
index f8cf62c717..0ce530ab08 100644
--- a/packages/api/src/stream/SteeringLifecycle.ts
+++ b/packages/api/src/stream/SteeringLifecycle.ts
@@ -150,6 +150,17 @@ export class SteeringLifecycle {
return this.store.removeSteer(streamId, steerId);
}
+ /**
+ * Escalate a still-queued steer to an interrupt IN PLACE — the durable
+ * `preempt` flag flips on the existing item, so its FIFO position survives
+ * (the whole queue drains at the seal, in order). Races with a drain,
+ * cancel, or replacement run settle inside the store's atomic update:
+ * `false` simply means the steer is no longer this generation's to arm.
+ */
+ arm(streamId: string, steerId: string, expectedCreatedAt?: number): Promise {
+ return this.store.armSteer(streamId, steerId, expectedCreatedAt);
+ }
+
/** Drop any queued steers (terminal cleanup backstop). */
clear(streamId: string): Promise {
return this.store.clearSteers(streamId);
diff --git a/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts b/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts
index 2dabfb47d7..f47b8969a9 100644
--- a/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts
+++ b/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts
@@ -388,6 +388,92 @@ describe('RedisJobStore Integration Tests', () => {
});
});
+ describe('Steer queue arm (in-place escalation)', () => {
+ test('arms a queued steer in place, preserving FIFO order and every field', async () => {
+ if (!ioredisClient) {
+ return;
+ }
+ const { RedisJobStore } = await import('../implementations/RedisJobStore');
+ const store = new RedisJobStore(ioredisClient);
+ await store.initialize();
+ const streamId = `arm-steer-${Date.now()}`;
+
+ try {
+ const job = await store.createJob(streamId, 'user-1', streamId);
+ await store.enqueueSteer(streamId, {
+ steerId: 'first',
+ text: 'earlier instruction',
+ userId: 'user-1',
+ createdAt: 1,
+ files: [{ file_id: 'f1' }] as never,
+ });
+ await store.enqueueSteer(streamId, {
+ steerId: 'second',
+ text: 'later instruction',
+ userId: 'user-1',
+ createdAt: 2,
+ });
+
+ await expect(store.armSteer(streamId, 'first', job.createdAt)).resolves.toBe(true);
+
+ const queue = await store.peekSteers(streamId);
+ expect(queue.map((item) => item.steerId)).toEqual(['first', 'second']);
+ /** Whole-item decode/patch/encode: nothing but the flag changes. */
+ expect(queue[0]).toMatchObject({
+ steerId: 'first',
+ text: 'earlier instruction',
+ userId: 'user-1',
+ createdAt: 1,
+ preempt: true,
+ });
+ expect(queue[0].files).toEqual([{ file_id: 'f1' }]);
+ expect(queue[1].preempt).toBeUndefined();
+ } finally {
+ await store.destroy();
+ }
+ });
+
+ test('refuses a missing steer, a stale generation, and a closed queue', async () => {
+ if (!ioredisClient) {
+ return;
+ }
+ const { RedisJobStore } = await import('../implementations/RedisJobStore');
+ const store = new RedisJobStore(ioredisClient);
+ await store.initialize();
+ const streamId = `arm-steer-guards-${Date.now()}`;
+
+ try {
+ const job = await store.createJob(streamId, 'user-1', streamId);
+ await store.enqueueSteer(streamId, {
+ steerId: 'kept',
+ text: 'still waiting',
+ userId: 'user-1',
+ createdAt: 1,
+ });
+
+ await expect(store.armSteer(streamId, 'absent', job.createdAt)).resolves.toBe(false);
+ await expect(store.armSteer(streamId, 'kept', job.createdAt + 999)).resolves.toBe(false);
+ expect((await store.peekSteers(streamId))[0].preempt).toBeUndefined();
+
+ /** `enqueueSteer` refuses once closed, so plant a raw item directly to
+ * exercise the closed guard with something findable in the list. */
+ await store.closeAndDrainSteers(streamId, job.createdAt);
+ await ioredisClient.rpush(
+ `stream:{${streamId}}:steers`,
+ JSON.stringify({
+ steerId: 'kept',
+ text: 'still waiting',
+ userId: 'user-1',
+ createdAt: 1,
+ }),
+ );
+ await expect(store.armSteer(streamId, 'kept', job.createdAt)).resolves.toBe(false);
+ } finally {
+ await store.destroy();
+ }
+ });
+ });
+
describe('Requires Action Status Tracking', () => {
test('should count requires_action jobs and remove them from the running set', async () => {
if (!ioredisClient) {
diff --git a/packages/api/src/stream/implementations/InMemoryJobStore.ts b/packages/api/src/stream/implementations/InMemoryJobStore.ts
index fb1865dabb..459a2f57e2 100644
--- a/packages/api/src/stream/implementations/InMemoryJobStore.ts
+++ b/packages/api/src/stream/implementations/InMemoryJobStore.ts
@@ -661,6 +661,22 @@ export class InMemoryJobStore implements IJobStore {
return true;
}
+ async armSteer(streamId: string, steerId: string, expectedCreatedAt?: number): Promise {
+ const job = this.jobs.get(streamId);
+ if (!job || this.closedSteerQueues.has(streamId)) {
+ return false;
+ }
+ if (expectedCreatedAt != null && job.createdAt !== expectedCreatedAt) {
+ return false;
+ }
+ const item = this.steerQueues.get(streamId)?.find((entry) => entry.steerId === steerId);
+ if (item == null) {
+ return false;
+ }
+ item.preempt = true;
+ return true;
+ }
+
async parkSteers(streamId: string, payload: string, expectedCreatedAt?: number): Promise {
if (expectedCreatedAt != null && this.jobs.get(streamId)?.createdAt !== expectedCreatedAt) {
return;
diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts
index 4bc31f4500..0b76894128 100644
--- a/packages/api/src/stream/implementations/RedisJobStore.ts
+++ b/packages/api/src/stream/implementations/RedisJobStore.ts
@@ -390,6 +390,32 @@ const STEER_REMOVE_LUA =
'end ' +
'return 1';
+/**
+ * Escalate ONE queued steer to an interrupt IN PLACE: decode the whole item,
+ * set `preempt`, and LSET it back at its index, so its FIFO position is
+ * untouched (the entire queue drains at the seal, in order). Guarded like
+ * {@link STEER_ENQUEUE_LUA}: a closed queue or a generation mismatch refuses,
+ * so a stale request can never arm a replacement run's steer.
+ *
+ * KEYS: [job, steers]
+ * ARGV: [steerIdFragment, expectedCreatedAt or ""]
+ * Returns: 1 armed, 0 not found / closed / fenced
+ */
+const STEER_ARM_LUA =
+ 'if ARGV[2] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[2] then return 0 end ' +
+ 'if redis.call("HGET", KEYS[1], "steersClosed") == "1" then return 0 end ' +
+ 'local items = redis.call("LRANGE", KEYS[2], 0, -1) ' +
+ 'for i = 1, #items do ' +
+ 'if string.find(items[i], ARGV[1], 1, true) then ' +
+ 'local decoded, item = pcall(cjson.decode, items[i]) ' +
+ 'if not decoded then return 0 end ' +
+ 'item.preempt = true ' +
+ 'redis.call("LSET", KEYS[2], i - 1, cjson.encode(item)) ' +
+ 'return 1 ' +
+ 'end ' +
+ 'end ' +
+ 'return 0';
+
/**
* Claim-on-read for parked steers: return AND delete in one atomic step so a
* second reload cannot re-mint chips the user already dismissed. The owner
@@ -1879,6 +1905,18 @@ export class RedisJobStore implements IJobStore {
return removed === 1;
}
+ async armSteer(streamId: string, steerId: string, expectedCreatedAt?: number): Promise {
+ const armed = (await this.redis.eval(
+ STEER_ARM_LUA,
+ 2,
+ KEYS.job(streamId),
+ KEYS.steers(streamId),
+ `"steerId":"${steerId}"`,
+ expectedCreatedAt != null ? String(expectedCreatedAt) : '',
+ )) as number;
+ return armed === 1;
+ }
+
async parkSteers(streamId: string, payload: string, expectedCreatedAt?: number): Promise {
const ttl = this.ttl.completed > 0 ? this.ttl.completed : PARKED_RECOVERY_TTL_S;
await this.redis.eval(
diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts
index fe5313f50a..14a82c5543 100644
--- a/packages/api/src/stream/interfaces/IJobStore.ts
+++ b/packages/api/src/stream/interfaces/IJobStore.ts
@@ -683,6 +683,16 @@ export interface IJobStore {
* False when it was no longer queued — already drained or run ended. */
removeSteer(streamId: string, steerId: string): Promise;
+ /**
+ * Atomically set `preempt: true` on ONE queued steer IN PLACE, preserving
+ * its FIFO position (the user escalated a waiting steer to an interrupt;
+ * the whole queue drains at the seal, so its order must not change).
+ * Guarded like {@link enqueueSteer}: refuses when the queue is closed or,
+ * with `expectedCreatedAt`, when the stream belongs to another generation.
+ * False when the steer is no longer queued — drained, cancelled, or fenced.
+ */
+ armSteer(streamId: string, steerId: string, expectedCreatedAt?: number): Promise;
+
/**
* Persist terminally-drained steers under their OWN bounded-TTL key so a
* client with no live subscriber can recover them via the status route.