@@ -4,7 +4,7 @@ import { PromptInput } from "@opencode/schema/prompt-input"
44import { SessionInbox } from "@opencode/schema/session-inbox"
55import { SessionMessage } from "@opencode/schema/session-message"
66import { Schema } from "effect"
7- import { provider } from "../utils/app"
7+ import { holdRoute , provider , sessionHref } from "../utils/app"
88import type { MockServerConfig } from "../utils/mock-server"
99import { openSession } from "../utils/workspace"
1010
@@ -95,6 +95,8 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
9595 const body = decodePromptBody ( input . body )
9696 prompts . push ( body )
9797 log . push ( `prompt:${ body . delivery ?? "steer" } ` )
98+ // The real server is first-admission-wins for a same-Session retry with the same ID.
99+ if ( rows . some ( ( row ) => row . id === body . id && row . sessionID === input . sessionID ) ) return
98100
99101 const row : InboxRow = {
100102 id : body . id ?? `inb_mock_${ sequence } ` ,
@@ -160,6 +162,7 @@ async function openQueue(
160162 mock : ReturnType < typeof createQueueMock > ,
161163 followUpBehavior ?: "queue" | "steer" ,
162164 revert ?: string ,
165+ keybinds ?: Record < string , string > ,
163166) {
164167 const model = { id : "queue-model" , name : "Queue Model" }
165168
@@ -184,6 +187,7 @@ async function openQueue(
184187 }
185188
186189 if ( followUpBehavior ) options . seed = { settings : { general : { followUpBehavior } } }
190+ if ( keybinds ) options . seed = { settings : { ...options . seed ?. settings , keybinds } }
187191 await openSession ( page , options )
188192 const composer = page . locator ( '[data-component="composer"]' )
189193
@@ -223,6 +227,132 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
223227 await expect ( view . input ) . toHaveText ( "" )
224228} )
225229
230+ for ( const action of [ "Undo" , "Delete" , "Move to queue" ] as const ) {
231+ test ( `${ action } waits for optimistic admission before changing pending input` , async ( { page } ) => {
232+ const mock = createQueueMock ( [ ] )
233+ const view = await openQueue ( page , mock , action === "Undo" ? "queue" : "steer" )
234+ const admission = await holdRoute ( page , ( url ) => url . pathname === `/api/session/${ sessionID } /prompt` , {
235+ method : "POST" ,
236+ } )
237+ await view . input . fill ( "Withdraw this input" )
238+ await view . input . press ( "Enter" )
239+ const request = await admission . arrived
240+ const row =
241+ action === "Undo"
242+ ? view . rows . filter ( { hasText : "Withdraw this input" } )
243+ : userRow ( page , request . postDataJSON ( ) . id )
244+ await row . hover ( )
245+ const control = row . getByRole ( "button" , { name : action } )
246+ const changed = page . waitForResponse (
247+ ( response ) =>
248+ response . request ( ) . method ( ) !== "OPTIONS" &&
249+ new URL ( response . url ( ) ) . pathname === `/api/session/${ sessionID } /inbox/${ request . postDataJSON ( ) . id } ` ,
250+ )
251+ await control . click ( )
252+ if ( action === "Undo" ) await expect ( control ) . toBeDisabled ( )
253+ expect ( mock . changes ) . toEqual ( [ ] )
254+ admission . release ( )
255+ expect ( ( await changed ) . ok ( ) ) . toBe ( true )
256+ await expect ( view . rows ) . toHaveCount ( action === "Move to queue" ? 1 : 0 )
257+ await expect ( view . input ) . toHaveText ( action === "Undo" ? "Withdraw this input" : "" )
258+ const change = action === "Move to queue" ? "queue" : "cancel"
259+ expect ( mock . changes ) . toEqual ( [ { inboxID : request . postDataJSON ( ) . id , action : change } ] )
260+ expect ( mock . log ) . toEqual ( [
261+ action === "Undo" ? "prompt:queue" : "prompt:steer" ,
262+ `${ change } :${ request . postDataJSON ( ) . id } ` ,
263+ ] )
264+ } )
265+ }
266+
267+ test ( "Undo does not withdraw a follow-up submitted after the undo gesture" , async ( { page } ) => {
268+ const mock = createQueueMock (
269+ [ ] ,
270+ [ { id : "msg_existing" , type : "user" , text : "Existing history" , time : { created : 1 } } ] ,
271+ )
272+ const view = await openQueue ( page , mock , "steer" , undefined , { "session.undo" : "ctrl+alt+shift+u" } )
273+ const admission = await holdRoute ( page , ( url ) => url . pathname === `/api/session/${ sessionID } /prompt` , {
274+ method : "POST" ,
275+ } )
276+ await view . input . fill ( "First input" )
277+ await view . input . press ( "Enter" )
278+ const first = await admission . arrived
279+ await expect ( view . input ) . toHaveText ( "" )
280+ await expect ( userRow ( page , first . postDataJSON ( ) . id ) ) . toContainText ( "First input" )
281+ await page . keyboard . press ( "Control+Alt+Shift+u" )
282+ await view . input . fill ( "Later input" )
283+ await view . input . press ( "Enter" )
284+ await expect ( userRow ( page , first . postDataJSON ( ) . id ) ) . toContainText ( "First input" )
285+ admission . release ( )
286+ await expect . poll ( ( ) => mock . prompts . map ( ( prompt ) => prompt . text ) ) . toEqual ( [ "First input" , "Later input" ] )
287+ await expect . poll ( ( ) => mock . changes ) . toEqual ( [ { inboxID : first . postDataJSON ( ) . id , action : "cancel" } ] )
288+ await expect ( view . input ) . toHaveText ( "First input" )
289+ await expect . poll ( ( ) => mock . rows . map ( ( row ) => row . payload . text ) ) . toEqual ( [ "Later input" ] )
290+ } )
291+
292+ test ( "a revert waiting behind admission restores only its captured session after navigation" , async ( { page } ) => {
293+ const otherID = "ses_revert_other"
294+ const message = { id : "msg_revert_owned" , type : "user" as const , text : "Restore A" , time : { created : 1 } }
295+ const mock = createQueueMock ( [ ] , [ message ] )
296+ const workspace = await openSession ( page , {
297+ name : "SessionQueueRegression" ,
298+ sessions : [
299+ { id : sessionID , title : "Session A" } ,
300+ { id : otherID , title : "Session B" } ,
301+ ] ,
302+ pageMessages : ( id ) => ( { items : id === sessionID ? mock . messages : [ ] } ) ,
303+ inbox : ( ) => mock . rows ,
304+ sessionStatus : ( ) => ( { [ sessionID ] : { type : "running" } } ) ,
305+ onPrompt : mock . onPrompt ,
306+ onInboxChange : mock . onInboxChange ,
307+ events : mock . events ,
308+ seed : { settings : { general : { followUpBehavior : "queue" } } } ,
309+ } )
310+ const admission = await holdRoute ( page , ( url ) => url . pathname === `/api/session/${ sessionID } /prompt` , {
311+ method : "POST" ,
312+ } )
313+ await workspace . editor . fill ( "Admit before revert" )
314+ await workspace . editor . press ( "Enter" )
315+ await admission . arrived
316+ await expect ( workspace . editor ) . toHaveText ( "" )
317+ const delivered = userRow ( page , message . id )
318+ await delivered . hover ( )
319+ await delivered . getByRole ( "button" , { name : "Revert message" } ) . click ( )
320+ await page . locator ( `a[data-titlebar-tab-link][href="${ sessionHref ( otherID ) } "]` ) . click ( )
321+ await expect ( workspace . editor ) . toBeEditable ( )
322+ await workspace . editor . fill ( "Keep B's draft" )
323+ const staged = page . waitForResponse (
324+ ( response ) => new URL ( response . url ( ) ) . pathname === `/api/session/${ sessionID } /revert/stage` ,
325+ )
326+ admission . release ( )
327+ expect ( ( await staged ) . ok ( ) ) . toBe ( true )
328+ await expect ( workspace . editor ) . toHaveText ( "Keep B's draft" )
329+ await page . locator ( `a[data-titlebar-tab-link][href="${ sessionHref ( sessionID ) } "]` ) . click ( )
330+ await expect ( workspace . editor ) . toHaveText ( "Restore A" )
331+ } )
332+
333+ test ( "an edit refuses a revert the server has staged before its event reaches the client" , async ( { page } ) => {
334+ const mock = createQueueMock ( [ "Edit this input" ] )
335+ const view = await openQueue ( page , mock , "queue" )
336+ await view . rows . getByText ( "Edit this input" , { exact : true } ) . click ( )
337+ await expect ( view . input ) . toHaveText ( "Edit this input" )
338+ await expect ( view . input ) . toBeFocused ( )
339+ await view . input . press ( "End" )
340+ await view . input . pressSequentially ( ", changed" )
341+ await expect ( view . input ) . toHaveText ( "Edit this input, changed" )
342+ await page . route (
343+ ( url ) => url . pathname === `/api/session/${ sessionID } ` ,
344+ ( route ) => {
345+ if ( route . request ( ) . method ( ) !== "GET" ) return route . fallback ( )
346+ return route . fulfill ( { json : { data : { revert : { messageID : "msg_boundary" } } } } )
347+ } ,
348+ )
349+ await view . input . press ( "Enter" )
350+ await expect ( page . getByText ( "Redo the revert before you reorder or edit queued prompts" ) ) . toBeVisible ( )
351+ await expect ( view . input ) . toHaveText ( "Edit this input, changed" )
352+ expect ( mock . prompts ) . toEqual ( [ ] )
353+ expect ( mock . changes ) . toEqual ( [ ] )
354+ } )
355+
226356test ( "dragging reorders queued prompts" , async ( { page } ) => {
227357 const mock = createQueueMock ( [ "first queued prompt" , "second queued prompt" , "third queued prompt" ] )
228358 mock . rows [ 0 ] . payload . files = [
@@ -298,7 +428,7 @@ for (const change of ["reorder", "edit"] as const) {
298428
299429 // The replacements admitted before the failure are withdrawn; the originals stay in place.
300430 await expect . poll ( ( ) => mock . log . includes ( `cancel:${ mock . prompts . at ( - 1 ) ?. id } ` ) ) . toBe ( true )
301- await expect . poll ( ( ) => mock . changes . length ) . toBe ( mock . prompts . length )
431+ await expect . poll ( ( ) => mock . changes . length ) . toBe ( new Set ( mock . prompts . map ( ( prompt ) => prompt . id ) ) . size )
302432 expect ( mock . changes . every ( ( entry ) => entry . action === "cancel" && ! entry . inboxID . startsWith ( "inb_seed_" ) ) ) . toBe (
303433 true ,
304434 )
0 commit comments