@@ -2151,11 +2151,6 @@ describe('useChat remount send recovery', () => {
21512151 expect ( allQueuedMessages ( ) ) . toHaveLength ( 0 )
21522152 } )
21532153
2154- /**
2155- * After a Stop of the first message, only the follow-up was the user's
2156- * intent: the Stop's POST is left to the server, nothing withdraws it, and the
2157- * next mount sends just the follow-up, once.
2158- */
21592154 /**
21602155 * A first message held at the queue head after a remount may already be a
21612156 * turn on the server. Editing it would send different text under a new id,
@@ -2204,6 +2199,159 @@ describe('useChat remount send recovery', () => {
22042199 } )
22052200 } )
22062201
2202+ /** A chat with a turn running, so anything sent to it waits in its queue. */
2203+ function renderBusyChat ( id : string ) {
2204+ const history : MothershipChatHistory = {
2205+ id,
2206+ mode : 'agent' ,
2207+ title : 'Busy' ,
2208+ messages : [ ] ,
2209+ activeStreamId : 'turn-still-running' ,
2210+ resources : [ ] ,
2211+ }
2212+ mockRequestJson . mockImplementation ( ( ) => Promise . resolve ( { chat : history } ) )
2213+ vi . stubGlobal ( 'fetch' , async ( input : RequestInfo | URL , init ?: RequestInit ) => {
2214+ if ( String ( input ) . includes ( '/api/mothership/chat/stream' ) ) {
2215+ if ( String ( input ) . includes ( 'batch=true' ) ) {
2216+ return Response . json ( { success : true , events : [ ] , status : 'streaming' } )
2217+ }
2218+ return new Response ( new ReadableStream < Uint8Array > ( ) , {
2219+ headers : { 'Content-Type' : 'text/event-stream' } ,
2220+ } )
2221+ }
2222+ return fetchStub ( input , init )
2223+ } )
2224+ return { history, ...renderUseChatInChat ( id , history ) }
2225+ }
2226+
2227+ /**
2228+ * A send another surface withdrew arrives here under its original id, through
2229+ * the send event or the stored handoff. Queued behind a running turn, it may
2230+ * already be a turn on the server, so it can't be edited either.
2231+ */
2232+ it ( 'does not let a withdrawn send handed to a busy chat be edited' , async ( ) => {
2233+ const { history, getResult } = renderBusyChat ( 'chat-busy-on-handoff' )
2234+ await waitFor ( ( ) => getResult ( ) . isSending )
2235+ await act ( async ( ) => {
2236+ await getResult ( ) . sendMessage ( 'handed over from another surface' , undefined , undefined , {
2237+ resumeUserMessageId : 'withdrawn-attempt' ,
2238+ } )
2239+ } )
2240+ const queued = useMothershipQueueStore . getState ( ) . queues [ history . id ] ?. [ 0 ]
2241+ expect ( queued ?. resumeUserMessageId ) . toBe ( 'withdrawn-attempt' )
2242+
2243+ let edited : ReturnType < ReturnType < typeof useChat > [ 'editQueuedMessage' ] >
2244+ await act ( async ( ) => {
2245+ edited = getResult ( ) . editQueuedMessage ( queued ?. id ?? '' )
2246+ } )
2247+
2248+ expect ( edited ) . toBeUndefined ( )
2249+ expect ( getResult ( ) . editingQueuedId ) . toBeNull ( )
2250+ } )
2251+
2252+ /** A follow-up whose dispatch got no answer may have reached the server too. */
2253+ it ( 'does not let a queued follow-up be edited after its send got no answer' , async ( ) => {
2254+ const history : MothershipChatHistory = {
2255+ id : 'chat-follow-up-unanswered' ,
2256+ mode : 'agent' ,
2257+ title : 'Unanswered' ,
2258+ messages : [ ] ,
2259+ activeStreamId : null ,
2260+ resources : [ ] ,
2261+ }
2262+ mockRequestJson . mockImplementation ( ( ) => Promise . resolve ( { chat : history } ) )
2263+ vi . stubGlobal ( 'fetch' , async ( input : RequestInfo | URL , init ?: RequestInit ) => {
2264+ if ( String ( input ) === '/api/mothership/chat' && init ?. method === 'POST' ) {
2265+ state . postBodies . push ( JSON . parse ( String ( init . body ) ) )
2266+ throw new TypeError ( 'Failed to fetch' )
2267+ }
2268+ return fetchStub ( input , init )
2269+ } )
2270+ useMothershipQueueStore
2271+ . getState ( )
2272+ . enqueue ( history . id , { id : 'follow-up' , content : 'and the second invoice' } )
2273+ const { getResult } = renderUseChatInChat ( history . id , history )
2274+ await waitFor (
2275+ ( ) =>
2276+ useMothershipQueueStore . getState ( ) . queues [ history . id ] ?. [ 0 ] ?. resumeUserMessageId !==
2277+ undefined
2278+ )
2279+
2280+ let edited : ReturnType < ReturnType < typeof useChat > [ 'editQueuedMessage' ] >
2281+ await act ( async ( ) => {
2282+ edited = getResult ( ) . editQueuedMessage ( 'follow-up' )
2283+ } )
2284+
2285+ expect ( edited ) . toBeUndefined ( )
2286+ expect ( useMothershipQueueStore . getState ( ) . queues [ history . id ] ?. [ 0 ] ) . toMatchObject ( {
2287+ content : 'and the second invoice' ,
2288+ resumeUserMessageId : state . postBodies [ 0 ] . userMessageId ,
2289+ } )
2290+ } )
2291+
2292+ /**
2293+ * A held message the server then refuses as busy is known not to be a turn
2294+ * there: the server answers a retry of an admitted id as a duplicate, never
2295+ * as busy. The user can edit it again.
2296+ */
2297+ it ( 'lets a held message be edited again once the server refuses it as busy' , async ( ) => {
2298+ const history : MothershipChatHistory = {
2299+ id : 'chat-held-then-refused' ,
2300+ mode : 'agent' ,
2301+ title : 'Refused' ,
2302+ messages : [ ] ,
2303+ activeStreamId : null ,
2304+ resources : [ ] ,
2305+ }
2306+ mockRequestJson . mockImplementation ( ( ) => Promise . resolve ( { chat : history } ) )
2307+ vi . stubGlobal ( 'fetch' , async ( input : RequestInfo | URL , init ?: RequestInit ) => {
2308+ const url = String ( input )
2309+ if ( url === '/api/mothership/chat' && init ?. method === 'POST' ) {
2310+ state . postBodies . push ( JSON . parse ( String ( init . body ) ) )
2311+ return Response . json (
2312+ {
2313+ error : 'A response is already in progress for this chat.' ,
2314+ activeStreamId : 'turn-from-another-tab' ,
2315+ } ,
2316+ { status : 409 }
2317+ )
2318+ }
2319+ if ( url . includes ( '/api/mothership/chat/stream' ) ) {
2320+ if ( url . includes ( 'batch=true' ) ) {
2321+ return Response . json ( { success : true , events : [ ] , status : 'streaming' } )
2322+ }
2323+ return new Response ( new ReadableStream < Uint8Array > ( ) , {
2324+ headers : { 'Content-Type' : 'text/event-stream' } ,
2325+ } )
2326+ }
2327+ return fetchStub ( input , init )
2328+ } )
2329+ useMothershipQueueStore . getState ( ) . enqueue ( history . id , {
2330+ id : 'held-first' ,
2331+ content : 'inspect the workspace' ,
2332+ resumeUserMessageId : 'first-attempt' ,
2333+ admissionUnknown : true ,
2334+ } )
2335+ const { getResult } = renderUseChatInChat ( history . id , history )
2336+ await waitFor ( ( ) => state . postBodies . length === 1 )
2337+ await waitFor (
2338+ ( ) => useMothershipQueueStore . getState ( ) . queues [ history . id ] ?. [ 0 ] ?. id === 'held-first'
2339+ )
2340+
2341+ let edited : ReturnType < ReturnType < typeof useChat > [ 'editQueuedMessage' ] >
2342+ await act ( async ( ) => {
2343+ edited = getResult ( ) . editQueuedMessage ( 'held-first' )
2344+ } )
2345+
2346+ expect ( edited ?. content ) . toBe ( 'inspect the workspace' )
2347+ expect ( getResult ( ) . editingQueuedId ) . toBe ( 'held-first' )
2348+ } )
2349+
2350+ /**
2351+ * After a Stop of the first message, only the follow-up was the user's
2352+ * intent: the Stop's POST is left to the server, nothing withdraws it, and the
2353+ * next mount sends just the follow-up, once.
2354+ */
22072355 it ( 'sends only the follow-up after a failed Stop when the new-chat surface remounts' , async ( ) => {
22082356 stubFirstPostPendingThenAdmitted ( )
22092357 const first = renderHomeLikeSurface ( )
0 commit comments