@@ -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,199 @@ 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+ * Send-now on a resumed message whose Stop of the running turn does not
2294+ * settle sends nothing. That says nothing about the earlier attempt the
2295+ * message resumes, so it must stay uneditable.
2296+ */
2297+ it ( 'keeps a resumed message uneditable when its Send-now Stop does not settle' , async ( ) => {
2298+ state . abortSettlements = [ false , false , false , false ]
2299+ const { getResult } = renderUseChatInChat ( 'chat-a' )
2300+ await act ( async ( ) => {
2301+ void getResult ( ) . sendMessage ( 'Original request' )
2302+ } )
2303+ await waitFor ( ( ) => state . postBodies . length === 1 && getResult ( ) . isSending )
2304+ await act ( async ( ) => {
2305+ await getResult ( ) . sendMessage ( 'handed over from another surface' , undefined , undefined , {
2306+ resumeUserMessageId : 'withdrawn-attempt' ,
2307+ } )
2308+ } )
2309+ await waitFor ( ( ) => useMothershipQueueStore . getState ( ) . queues [ 'chat-a' ] ?. length === 1 )
2310+
2311+ await act ( async ( ) => {
2312+ await getResult ( )
2313+ . sendNow ( )
2314+ . catch ( ( ) => { } )
2315+ await sleep ( 200 )
2316+ } )
2317+ const queued = useMothershipQueueStore . getState ( ) . queues [ 'chat-a' ] ?. [ 0 ]
2318+ let edited : ReturnType < ReturnType < typeof useChat > [ 'editQueuedMessage' ] >
2319+ await act ( async ( ) => {
2320+ edited = getResult ( ) . editQueuedMessage ( queued ?. id ?? '' )
2321+ } )
2322+
2323+ expect ( state . postBodies ) . toHaveLength ( 1 )
2324+ expect ( queued ) . toMatchObject ( {
2325+ content : 'handed over from another surface' ,
2326+ resumeUserMessageId : 'withdrawn-attempt' ,
2327+ admissionUnknown : true ,
2328+ } )
2329+ expect ( edited ) . toBeUndefined ( )
2330+ } )
2331+
2332+ /**
2333+ * A held message the server then refuses as busy is known not to be a turn
2334+ * there: the server answers a retry of an admitted id as a duplicate, never
2335+ * as busy. The user can edit it again.
2336+ */
2337+ it ( 'lets a held message be edited again once the server refuses it as busy' , async ( ) => {
2338+ const history : MothershipChatHistory = {
2339+ id : 'chat-held-then-refused' ,
2340+ mode : 'agent' ,
2341+ title : 'Refused' ,
2342+ messages : [ ] ,
2343+ activeStreamId : null ,
2344+ resources : [ ] ,
2345+ }
2346+ mockRequestJson . mockImplementation ( ( ) => Promise . resolve ( { chat : history } ) )
2347+ vi . stubGlobal ( 'fetch' , async ( input : RequestInfo | URL , init ?: RequestInit ) => {
2348+ const url = String ( input )
2349+ if ( url === '/api/mothership/chat' && init ?. method === 'POST' ) {
2350+ state . postBodies . push ( JSON . parse ( String ( init . body ) ) )
2351+ return Response . json (
2352+ {
2353+ error : 'A response is already in progress for this chat.' ,
2354+ activeStreamId : 'turn-from-another-tab' ,
2355+ } ,
2356+ { status : 409 }
2357+ )
2358+ }
2359+ if ( url . includes ( '/api/mothership/chat/stream' ) ) {
2360+ if ( url . includes ( 'batch=true' ) ) {
2361+ return Response . json ( { success : true , events : [ ] , status : 'streaming' } )
2362+ }
2363+ return new Response ( new ReadableStream < Uint8Array > ( ) , {
2364+ headers : { 'Content-Type' : 'text/event-stream' } ,
2365+ } )
2366+ }
2367+ return fetchStub ( input , init )
2368+ } )
2369+ useMothershipQueueStore . getState ( ) . enqueue ( history . id , {
2370+ id : 'held-first' ,
2371+ content : 'inspect the workspace' ,
2372+ resumeUserMessageId : 'first-attempt' ,
2373+ admissionUnknown : true ,
2374+ } )
2375+ const { getResult } = renderUseChatInChat ( history . id , history )
2376+ await waitFor ( ( ) => state . postBodies . length === 1 )
2377+ await waitFor (
2378+ ( ) => useMothershipQueueStore . getState ( ) . queues [ history . id ] ?. [ 0 ] ?. id === 'held-first'
2379+ )
2380+
2381+ let edited : ReturnType < ReturnType < typeof useChat > [ 'editQueuedMessage' ] >
2382+ await act ( async ( ) => {
2383+ edited = getResult ( ) . editQueuedMessage ( 'held-first' )
2384+ } )
2385+
2386+ expect ( edited ?. content ) . toBe ( 'inspect the workspace' )
2387+ expect ( getResult ( ) . editingQueuedId ) . toBe ( 'held-first' )
2388+ } )
2389+
2390+ /**
2391+ * After a Stop of the first message, only the follow-up was the user's
2392+ * intent: the Stop's POST is left to the server, nothing withdraws it, and the
2393+ * next mount sends just the follow-up, once.
2394+ */
22072395 it ( 'sends only the follow-up after a failed Stop when the new-chat surface remounts' , async ( ) => {
22082396 stubFirstPostPendingThenAdmitted ( )
22092397 const first = renderHomeLikeSurface ( )
0 commit comments