@@ -2562,6 +2562,143 @@ describe('useChat remount send recovery', () => {
25622562 expect ( state . postBodies [ 1 ] . userMessageId ) . toBe ( state . postBodies [ 0 ] . userMessageId )
25632563 } )
25642564
2565+ /**
2566+ * A fresh-id Send-now is unsent while it waits on its Stop. Once its POST goes
2567+ * out the server may hold it, even before the chat's history shows it, so a
2568+ * reload with that POST unanswered must restore it uneditable.
2569+ */
2570+ it ( 'keeps a fresh Send-now uneditable when the page reloads with its POST unanswered' , async ( ) => {
2571+ const first = renderUseChatInChat ( 'chat-a' )
2572+ await act ( async ( ) => {
2573+ void first . getResult ( ) . sendMessage ( 'Original request' )
2574+ } )
2575+ await waitFor ( ( ) => state . postBodies . length === 1 && first . getResult ( ) . isSending )
2576+ await act ( async ( ) => {
2577+ void first . getResult ( ) . sendMessage ( 'Use the latest report' )
2578+ } )
2579+ await waitFor ( ( ) => useMothershipQueueStore . getState ( ) . queues [ 'chat-a' ] ?. length === 1 )
2580+ await act ( async ( ) => {
2581+ void first . getResult ( ) . sendNow ( )
2582+ } )
2583+ /** The Send-now POST is out, and held open. */
2584+ await waitFor ( ( ) => state . postBodies . length === 2 )
2585+ const storedAtReload = readQueuedSendHandoffState ( )
2586+ expect ( storedAtReload ?. userMessageId ) . toBe ( state . postBodies [ 1 ] . userMessageId )
2587+ first . unmount ( )
2588+ await act ( async ( ) => {
2589+ await sleep ( 50 )
2590+ } )
2591+ /* A reload runs no cleanup: the queue comes back as the session saved it (the
2592+ dispatched entry had left it) and the handoff as it was stored. */
2593+ useMothershipQueueStore . getState ( ) . reset ( )
2594+ if ( storedAtReload ) writeQueuedSendHandoffState ( storedAtReload )
2595+
2596+ const history : MothershipChatHistory = {
2597+ id : 'chat-a' ,
2598+ mode : 'agent' ,
2599+ title : 'Reloaded' ,
2600+ messages : [ ] ,
2601+ activeStreamId : null ,
2602+ resources : [ ] ,
2603+ }
2604+ let loadHistory : ( ( ) => void ) | undefined
2605+ mockRequestJson . mockImplementation (
2606+ ( ) =>
2607+ new Promise ( ( resolve ) => {
2608+ loadHistory = ( ) => resolve ( { chat : history } )
2609+ } )
2610+ )
2611+ const reloaded = renderUseChatInChat ( 'chat-a' )
2612+ const editAtRestore : Array < ReturnType < ReturnType < typeof useChat > [ 'editQueuedMessage' ] > > = [ ]
2613+ let tried = false
2614+ const unsubscribe = useMothershipQueueStore . subscribe ( ( queueState ) => {
2615+ if ( tried ) return
2616+ const restored = queueState . queues [ 'chat-a' ] ?. find (
2617+ ( message ) => message . content === 'Use the latest report'
2618+ )
2619+ if ( ! restored ) return
2620+ tried = true
2621+ editAtRestore . push ( reloaded . getResult ( ) . editQueuedMessage ( restored . id ) )
2622+ } )
2623+ try {
2624+ await waitFor ( ( ) => loadHistory !== undefined )
2625+ await act ( async ( ) => {
2626+ loadHistory ?.( )
2627+ await sleep ( 50 )
2628+ } )
2629+ await waitFor ( ( ) => tried )
2630+ } finally {
2631+ unsubscribe ( )
2632+ }
2633+
2634+ expect ( editAtRestore [ 0 ] ) . toBeUndefined ( )
2635+ } )
2636+
2637+ /**
2638+ * A resumed message whose earlier attempt is the very turn now running, sent
2639+ * now: its Stop and its resend share one id, and the server answers the resend
2640+ * as a duplicate of that turn. That is not a refusal, so the message must
2641+ * never come back editable.
2642+ */
2643+ it ( 'never treats a conflict naming the resent id itself as a refusal' , async ( ) => {
2644+ const history : MothershipChatHistory = {
2645+ id : 'chat-own-id' ,
2646+ mode : 'agent' ,
2647+ title : 'Own id' ,
2648+ messages : [ ] ,
2649+ activeStreamId : 'earlier-attempt' ,
2650+ resources : [ ] ,
2651+ }
2652+ mockRequestJson . mockImplementation ( ( ) => Promise . resolve ( { chat : history } ) )
2653+ vi . stubGlobal ( 'fetch' , async ( input : RequestInfo | URL , init ?: RequestInit ) => {
2654+ const url = String ( input )
2655+ if ( url . includes ( '/api/copilot/chat/abort' ) ) {
2656+ state . abortBodies . push ( JSON . parse ( String ( init ?. body ) ) )
2657+ return Response . json ( { aborted : true , settled : true } )
2658+ }
2659+ if ( url === '/api/mothership/chat' && init ?. method === 'POST' ) {
2660+ state . postBodies . push ( JSON . parse ( String ( init . body ) ) )
2661+ return Response . json (
2662+ { error : 'This message was already sent.' , activeStreamId : 'earlier-attempt' } ,
2663+ { status : 409 }
2664+ )
2665+ }
2666+ if ( url . includes ( '/api/mothership/chat/stream' ) ) {
2667+ if ( url . includes ( 'batch=true' ) ) {
2668+ return Response . json ( { success : true , events : [ ] , status : 'streaming' } )
2669+ }
2670+ return new Response ( new ReadableStream < Uint8Array > ( ) , {
2671+ headers : { 'Content-Type' : 'text/event-stream' } ,
2672+ } )
2673+ }
2674+ return fetchStub ( input , init )
2675+ } )
2676+ const { getResult } = renderUseChatInChat ( history . id , history )
2677+ await waitFor ( ( ) => getResult ( ) . isSending )
2678+ useMothershipQueueStore . getState ( ) . enqueue ( history . id , {
2679+ id : 'resumed' ,
2680+ content : 'sent earlier with no answer' ,
2681+ resumeUserMessageId : 'earlier-attempt' ,
2682+ admissionUnknown : true ,
2683+ } )
2684+ await act ( async ( ) => {
2685+ /** Reattaches to the running turn, which stays open. */
2686+ void getResult ( )
2687+ . sendNow ( 'resumed' )
2688+ . catch ( ( ) => { } )
2689+ } )
2690+ await waitFor ( ( ) => state . postBodies . length === 1 )
2691+ await act ( async ( ) => {
2692+ await sleep ( 300 )
2693+ } )
2694+
2695+ expect ( state . postBodies . map ( ( body ) => body . userMessageId ) ) . toEqual ( [ 'earlier-attempt' ] )
2696+ const requeued = useMothershipQueueStore
2697+ . getState ( )
2698+ . queues [ history . id ] ?. find ( ( message ) => message . content === 'sent earlier with no answer' )
2699+ expect ( requeued === undefined || requeued . admissionUnknown === true ) . toBe ( true )
2700+ } )
2701+
25652702 /**
25662703 * A held message the server then refuses as busy is known not to be a turn
25672704 * there: the server answers a retry of an admitted id as a duplicate, never
0 commit comments