diff --git a/client/src/hooks/Chat/__tests__/useSteerConvert.spec.tsx b/client/src/hooks/Chat/__tests__/useSteerConvert.spec.tsx index 613e5aca37..97e9cb7acd 100644 --- a/client/src/hooks/Chat/__tests__/useSteerConvert.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useSteerConvert.spec.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { act, renderHook } from '@testing-library/react'; -import { RecoilRoot, useRecoilValue, type MutableSnapshot } from 'recoil'; +import { RecoilRoot, useRecoilValue, useSetRecoilState, type MutableSnapshot } from 'recoil'; import useSteerConvert from '../useSteerConvert'; import store from '~/store'; @@ -16,12 +16,17 @@ function setup(initialize?: (snapshot: MutableSnapshot) => void) { {children} ); return renderHook( - () => ({ - convert: useSteerConvert(), - chips: useRecoilValue(store.pendingSteersByConvoId(CONVO_ID)), - queue: useRecoilValue(store.queuedMessagesByConvoId(CONVO_ID)), - applied: useRecoilValue(store.appliedSteerIdsByConvoId(CONVO_ID)), - }), + () => { + const setQueue = useSetRecoilState(store.queuedMessagesByConvoId(CONVO_ID)); + return { + convert: useSteerConvert(), + chips: useRecoilValue(store.pendingSteersByConvoId(CONVO_ID)), + queue: useRecoilValue(store.queuedMessagesByConvoId(CONVO_ID)), + applied: useRecoilValue(store.appliedSteerIdsByConvoId(CONVO_ID)), + // Mirrors `useQueueDrain` dequeuing the head item after auto-send. + drainQueue: () => setQueue((prev) => prev.slice(1)), + }; + }, { wrapper }, ); } @@ -112,6 +117,29 @@ describe('useSteerConvert', () => { expect(result.current.applied).toEqual(['srv-2']); }); + it('does not re-queue a steer already drained after conversion', () => { + const { result } = setup(); + const steers = [{ steerId: 'srv-drained', text: 'submitted once', createdAt: 5 }]; + // First delivery converts the leftover steer into a queued chip. + act(() => { + result.current.convert(CONVO_ID, steers); + }); + expect(result.current.queue).toEqual([expect.objectContaining({ id: 'srv-drained' })]); + // The run-end drain submits it and removes it from the queue. + act(() => { + result.current.drainQueue(); + }); + expect(result.current.queue).toEqual([]); + // A late redelivery of the SAME steer (claimParked /chat/status, abort + // response, or reconnect) must NOT resurrect a queued chip for a message + // that was already sent — the applied-id set marks it settled. + act(() => { + result.current.convert(CONVO_ID, steers); + }); + expect(result.current.queue).toEqual([]); + expect(result.current.applied).toEqual(['srv-drained']); + }); + describe('claimParked (clears the parked server copy of live-delivered steers)', () => { beforeEach(() => { mockFetchStreamStatus.mockReset(); diff --git a/client/src/hooks/Chat/useSteerConvert.ts b/client/src/hooks/Chat/useSteerConvert.ts index 68a1a7901a..1c01a2140e 100644 --- a/client/src/hooks/Chat/useSteerConvert.ts +++ b/client/src/hooks/Chat/useSteerConvert.ts @@ -46,6 +46,15 @@ export default function useSteerConvert() { .getValue(); const chipById = new Map(localChips.map((chip) => [chip.steerId, chip])); const steerIds = new Set(steers.map((steer) => steer.steerId)); + // Steers already settled (applied on the server OR converted here on an + // earlier delivery) must not re-enter the queue. Read BEFORE the append + // below so a first-time conversion still queues, but a redelivery whose + // item was already DRAINED out of the queue is a no-op — without this, + // the queue-only dedup below misses a message the run-end drain already + // submitted and re-mints it as a stranded queued chip. + const settledSteerIds = new Set( + snapshot.getLoadable(store.appliedSteerIdsByConvoId(conversationId)).getValue(), + ); set(store.appliedSteerIdsByConvoId(conversationId), (prev) => appendAppliedSteerIds( prev, @@ -57,7 +66,11 @@ export default function useSteerConvert() { ); set(store.queuedMessagesByConvoId(conversationId), (prev) => { const fresh = steers - .filter((steer) => !prev.some((queued) => queued.id === steer.steerId)) + .filter( + (steer) => + !settledSteerIds.has(steer.steerId) && + !prev.some((queued) => queued.id === steer.steerId), + ) .map((steer) => ({ id: steer.steerId, text: steer.text,