@@ -2053,9 +2053,11 @@ describe('useChat remount send recovery', () => {
20532053 } )
20542054
20552055 /**
2056- * The first POST on the new-chat surface never answers; later POSTs open a
2057- * turn in the chat the first message created. The abort endpoint and the
2058- * stream lookup fail, as they would for a Stop that cannot reach the server.
2056+ * The first POST on the new-chat surface reaches the server, which admits it,
2057+ * but its answer never arrives. A resend under that id gets the server's
2058+ * dedupe answer naming the chat it opened; any other POST opens a turn in
2059+ * that chat. Until the remount, the abort endpoint and the stream lookup
2060+ * fail, as they would for a Stop that cannot reach the server.
20592061 */
20602062 function stubFirstPostPendingThenAdmitted ( ) {
20612063 vi . stubGlobal ( 'fetch' , async ( input : RequestInfo | URL , init ?: RequestInit ) => {
@@ -2069,6 +2071,17 @@ describe('useChat remount send recovery', () => {
20692071 } )
20702072 } )
20712073 }
2074+ const firstId = state . postBodies [ 0 ] . userMessageId
2075+ if ( state . postBodies . at ( - 1 ) ?. userMessageId === firstId ) {
2076+ return Response . json (
2077+ {
2078+ error : 'This message was already sent.' ,
2079+ activeStreamId : firstId ,
2080+ chatId : DEDUPED_CHAT_ID ,
2081+ } ,
2082+ { status : 409 }
2083+ )
2084+ }
20722085 return new Response (
20732086 new ReadableStream < Uint8Array > ( {
20742087 start ( controller ) {
@@ -2113,6 +2126,13 @@ describe('useChat remount send recovery', () => {
21132126 } )
21142127 await waitFor ( ( ) => allQueuedMessages ( ) . length === 1 )
21152128 first . unmount ( )
2129+ /** Held first, as written: the server may already have it under its id. */
2130+ expect (
2131+ allQueuedMessages ( ) . map ( ( message ) => [ message . content , message . admissionUnknown ] )
2132+ ) . toEqual ( [
2133+ [ 'inspect the workspace' , true ] ,
2134+ [ 'follow-up while admission pending' , undefined ] ,
2135+ ] )
21162136
21172137 const second = renderHomeLikeSurface ( )
21182138 await waitFor ( ( ) => state . postBodies . length >= 3 , 4_000 )
@@ -2136,6 +2156,54 @@ describe('useChat remount send recovery', () => {
21362156 * intent: the Stop's POST is left to the server, nothing withdraws it, and the
21372157 * next mount sends just the follow-up, once.
21382158 */
2159+ /**
2160+ * A first message held at the queue head after a remount may already be a
2161+ * turn on the server. Editing it would send different text under a new id,
2162+ * a second message the user never meant to send.
2163+ */
2164+ it ( 'does not let a withdrawn first message held at the queue head be edited' , async ( ) => {
2165+ const history : MothershipChatHistory = {
2166+ id : 'chat-running-while-held' ,
2167+ mode : 'agent' ,
2168+ title : 'Held' ,
2169+ messages : [ ] ,
2170+ activeStreamId : 'turn-still-running' ,
2171+ resources : [ ] ,
2172+ }
2173+ mockRequestJson . mockImplementation ( ( ) => Promise . resolve ( { chat : history } ) )
2174+ vi . stubGlobal ( 'fetch' , async ( input : RequestInfo | URL , init ?: RequestInit ) => {
2175+ if ( String ( input ) . includes ( '/api/mothership/chat/stream' ) ) {
2176+ if ( String ( input ) . includes ( 'batch=true' ) ) {
2177+ return Response . json ( { success : true , events : [ ] , status : 'streaming' } )
2178+ }
2179+ return new Response ( new ReadableStream < Uint8Array > ( ) , {
2180+ headers : { 'Content-Type' : 'text/event-stream' } ,
2181+ } )
2182+ }
2183+ return fetchStub ( input , init )
2184+ } )
2185+ useMothershipQueueStore . getState ( ) . enqueue ( history . id , {
2186+ id : 'held-first' ,
2187+ content : 'inspect the workspace' ,
2188+ resumeUserMessageId : 'first-attempt' ,
2189+ admissionUnknown : true ,
2190+ } )
2191+ const { getResult } = renderUseChatInChat ( history . id , history )
2192+ await waitFor ( ( ) => getResult ( ) . isSending )
2193+
2194+ let edited : ReturnType < ReturnType < typeof useChat > [ 'editQueuedMessage' ] >
2195+ await act ( async ( ) => {
2196+ edited = getResult ( ) . editQueuedMessage ( 'held-first' )
2197+ } )
2198+
2199+ expect ( edited ) . toBeUndefined ( )
2200+ expect ( getResult ( ) . editingQueuedId ) . toBeNull ( )
2201+ expect ( useMothershipQueueStore . getState ( ) . queues [ history . id ] ?. [ 0 ] ) . toMatchObject ( {
2202+ content : 'inspect the workspace' ,
2203+ resumeUserMessageId : 'first-attempt' ,
2204+ } )
2205+ } )
2206+
21392207 it ( 'sends only the follow-up after a failed Stop when the new-chat surface remounts' , async ( ) => {
21402208 stubFirstPostPendingThenAdmitted ( )
21412209 const first = renderHomeLikeSurface ( )
0 commit comments