@@ -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
@@ -14,6 +14,7 @@ const sessionID = "ses_session_queue_regression"
1414const PromptBody = Schema . Struct ( {
1515 id : SessionMessage . ID . pipe ( Schema . optional ) ,
1616 ...PromptInput . Prompt . fields ,
17+ selection : PromptInput . Selection . pipe ( Schema . optional ) ,
1718 metadata : SessionInbox . UserPayload . fields . metadata ,
1819 delivery : SessionInbox . Delivery . pipe ( Schema . optional ) ,
1920 resume : Schema . Boolean . pipe ( Schema . optional ) ,
@@ -95,6 +96,8 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
9596 const body = decodePromptBody ( input . body )
9697 prompts . push ( body )
9798 log . push ( `prompt:${ body . delivery ?? "steer" } ` )
99+ // The real server is first-admission-wins for a same-Session retry with the same ID.
100+ if ( rows . some ( ( row ) => row . id === body . id && row . sessionID === input . sessionID ) ) return
98101
99102 const row : InboxRow = {
100103 id : body . id ?? `inb_mock_${ sequence } ` ,
@@ -160,6 +163,7 @@ async function openQueue(
160163 mock : ReturnType < typeof createQueueMock > ,
161164 followUpBehavior ?: "queue" | "steer" ,
162165 revert ?: string ,
166+ keybinds ?: Record < string , string > ,
163167) {
164168 const model = { id : "queue-model" , name : "Queue Model" }
165169
@@ -184,6 +188,7 @@ async function openQueue(
184188 }
185189
186190 if ( followUpBehavior ) options . seed = { settings : { general : { followUpBehavior } } }
191+ if ( keybinds ) options . seed = { settings : { ...options . seed ?. settings , keybinds } }
187192 await openSession ( page , options )
188193 const composer = page . locator ( '[data-component="composer"]' )
189194
@@ -220,9 +225,137 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
220225 await view . input . fill ( "steer this correction" )
221226 await view . input . press ( "ControlOrMeta+Enter" )
222227 await expect . poll ( ( ) => mock . prompts . map ( ( prompt ) => prompt . delivery ) ) . toEqual ( [ "queue" , "steer" ] )
228+ expect ( mock . prompts [ 0 ] . selection ) . toBeUndefined ( )
229+ expect ( mock . prompts [ 1 ] . selection ) . toEqual ( { agent : "build" , model : { providerID : "opencode" , id : "queue-model" } } )
223230 await expect ( view . input ) . toHaveText ( "" )
224231} )
225232
233+ for ( const action of [ "Undo" , "Delete" , "Move to queue" ] as const ) {
234+ test ( `${ action } waits for optimistic admission before changing pending input` , async ( { page } ) => {
235+ const mock = createQueueMock ( [ ] )
236+ const view = await openQueue ( page , mock , action === "Undo" ? "queue" : "steer" )
237+ const admission = await holdRoute ( page , ( url ) => url . pathname === `/api/session/${ sessionID } /prompt` , {
238+ method : "POST" ,
239+ } )
240+ await view . input . fill ( "Withdraw this input" )
241+ await view . input . press ( "Enter" )
242+ const request = await admission . arrived
243+ const row =
244+ action === "Undo"
245+ ? view . rows . filter ( { hasText : "Withdraw this input" } )
246+ : userRow ( page , request . postDataJSON ( ) . id )
247+ await row . hover ( )
248+ const control = row . getByRole ( "button" , { name : action } )
249+ const changed = page . waitForResponse (
250+ ( response ) =>
251+ response . request ( ) . method ( ) !== "OPTIONS" &&
252+ new URL ( response . url ( ) ) . pathname === `/api/session/${ sessionID } /inbox/${ request . postDataJSON ( ) . id } ` ,
253+ )
254+ await control . click ( )
255+ if ( action === "Undo" ) await expect ( control ) . toBeDisabled ( )
256+ expect ( mock . changes ) . toEqual ( [ ] )
257+ admission . release ( )
258+ expect ( ( await changed ) . ok ( ) ) . toBe ( true )
259+ await expect ( view . rows ) . toHaveCount ( action === "Move to queue" ? 1 : 0 )
260+ await expect ( view . input ) . toHaveText ( action === "Undo" ? "Withdraw this input" : "" )
261+ const change = action === "Move to queue" ? "queue" : "cancel"
262+ expect ( mock . changes ) . toEqual ( [ { inboxID : request . postDataJSON ( ) . id , action : change } ] )
263+ expect ( mock . log ) . toEqual ( [
264+ action === "Undo" ? "prompt:queue" : "prompt:steer" ,
265+ `${ change } :${ request . postDataJSON ( ) . id } ` ,
266+ ] )
267+ } )
268+ }
269+
270+ test ( "Undo does not withdraw a follow-up submitted after the undo gesture" , async ( { page } ) => {
271+ const mock = createQueueMock (
272+ [ ] ,
273+ [ { id : "msg_existing" , type : "user" , text : "Existing history" , time : { created : 1 } } ] ,
274+ )
275+ const view = await openQueue ( page , mock , "steer" , undefined , { "session.undo" : "ctrl+alt+shift+u" } )
276+ const admission = await holdRoute ( page , ( url ) => url . pathname === `/api/session/${ sessionID } /prompt` , {
277+ method : "POST" ,
278+ } )
279+ await view . input . fill ( "First input" )
280+ await view . input . press ( "Enter" )
281+ const first = await admission . arrived
282+ await expect ( view . input ) . toHaveText ( "" )
283+ await expect ( userRow ( page , first . postDataJSON ( ) . id ) ) . toContainText ( "First input" )
284+ await page . keyboard . press ( "Control+Alt+Shift+u" )
285+ await view . input . fill ( "Later input" )
286+ await view . input . press ( "Enter" )
287+ await expect ( userRow ( page , first . postDataJSON ( ) . id ) ) . toContainText ( "First input" )
288+ admission . release ( )
289+ await expect . poll ( ( ) => mock . prompts . map ( ( prompt ) => prompt . text ) ) . toEqual ( [ "First input" , "Later input" ] )
290+ await expect . poll ( ( ) => mock . changes ) . toEqual ( [ { inboxID : first . postDataJSON ( ) . id , action : "cancel" } ] )
291+ await expect ( view . input ) . toHaveText ( "First input" )
292+ await expect . poll ( ( ) => mock . rows . map ( ( row ) => row . payload . text ) ) . toEqual ( [ "Later input" ] )
293+ } )
294+
295+ test ( "a revert waiting behind admission restores only its captured session after navigation" , async ( { page } ) => {
296+ const otherID = "ses_revert_other"
297+ const message = { id : "msg_revert_owned" , type : "user" as const , text : "Restore A" , time : { created : 1 } }
298+ const mock = createQueueMock ( [ ] , [ message ] )
299+ const workspace = await openSession ( page , {
300+ name : "SessionQueueRegression" ,
301+ sessions : [
302+ { id : sessionID , title : "Session A" } ,
303+ { id : otherID , title : "Session B" } ,
304+ ] ,
305+ pageMessages : ( id ) => ( { items : id === sessionID ? mock . messages : [ ] } ) ,
306+ inbox : ( ) => mock . rows ,
307+ sessionStatus : ( ) => ( { [ sessionID ] : { type : "running" } } ) ,
308+ onPrompt : mock . onPrompt ,
309+ onInboxChange : mock . onInboxChange ,
310+ events : mock . events ,
311+ seed : { settings : { general : { followUpBehavior : "queue" } } } ,
312+ } )
313+ const admission = await holdRoute ( page , ( url ) => url . pathname === `/api/session/${ sessionID } /prompt` , {
314+ method : "POST" ,
315+ } )
316+ await workspace . editor . fill ( "Admit before revert" )
317+ await workspace . editor . press ( "Enter" )
318+ await admission . arrived
319+ await expect ( workspace . editor ) . toHaveText ( "" )
320+ const delivered = userRow ( page , message . id )
321+ await delivered . hover ( )
322+ await delivered . getByRole ( "button" , { name : "Revert message" } ) . click ( )
323+ await page . locator ( `a[data-titlebar-tab-link][href="${ sessionHref ( otherID ) } "]` ) . click ( )
324+ await expect ( workspace . editor ) . toBeEditable ( )
325+ await workspace . editor . fill ( "Keep B's draft" )
326+ const staged = page . waitForResponse (
327+ ( response ) => new URL ( response . url ( ) ) . pathname === `/api/session/${ sessionID } /revert/stage` ,
328+ )
329+ admission . release ( )
330+ expect ( ( await staged ) . ok ( ) ) . toBe ( true )
331+ await expect ( workspace . editor ) . toHaveText ( "Keep B's draft" )
332+ await page . locator ( `a[data-titlebar-tab-link][href="${ sessionHref ( sessionID ) } "]` ) . click ( )
333+ await expect ( workspace . editor ) . toHaveText ( "Restore A" )
334+ } )
335+
336+ test ( "an edit refuses a revert the server has staged before its event reaches the client" , async ( { page } ) => {
337+ const mock = createQueueMock ( [ "Edit this input" ] )
338+ const view = await openQueue ( page , mock , "queue" )
339+ await view . rows . getByText ( "Edit this input" , { exact : true } ) . click ( )
340+ await expect ( view . input ) . toHaveText ( "Edit this input" )
341+ await expect ( view . input ) . toBeFocused ( )
342+ await view . input . press ( "End" )
343+ await view . input . pressSequentially ( ", changed" )
344+ await expect ( view . input ) . toHaveText ( "Edit this input, changed" )
345+ await page . route (
346+ ( url ) => url . pathname === `/api/session/${ sessionID } ` ,
347+ ( route ) => {
348+ if ( route . request ( ) . method ( ) !== "GET" ) return route . fallback ( )
349+ return route . fulfill ( { json : { data : { revert : { messageID : "msg_boundary" } } } } )
350+ } ,
351+ )
352+ await view . input . press ( "Enter" )
353+ await expect ( page . getByText ( "Redo the revert before you reorder or edit queued prompts" ) ) . toBeVisible ( )
354+ await expect ( view . input ) . toHaveText ( "Edit this input, changed" )
355+ expect ( mock . prompts ) . toEqual ( [ ] )
356+ expect ( mock . changes ) . toEqual ( [ ] )
357+ } )
358+
226359test ( "dragging reorders queued prompts" , async ( { page } ) => {
227360 const mock = createQueueMock ( [ "first queued prompt" , "second queued prompt" , "third queued prompt" ] )
228361 mock . rows [ 0 ] . payload . files = [
@@ -298,7 +431,7 @@ for (const change of ["reorder", "edit"] as const) {
298431
299432 // The replacements admitted before the failure are withdrawn; the originals stay in place.
300433 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 )
434+ await expect . poll ( ( ) => mock . changes . length ) . toBe ( new Set ( mock . prompts . map ( ( prompt ) => prompt . id ) ) . size )
302435 expect ( mock . changes . every ( ( entry ) => entry . action === "cancel" && ! entry . inboxID . startsWith ( "inb_seed_" ) ) ) . toBe (
303436 true ,
304437 )
@@ -445,6 +578,7 @@ test("editing a comment-only prompt keeps its notes once", async ({ page }) => {
445578 // With no display text, the editor shows the note itself, so the edit owns it as text.
446579 await view . rows . getByText ( note , { exact : true } ) . click ( )
447580 await expect ( view . input ) . toHaveText ( note )
581+ await expect ( view . input ) . toBeFocused ( )
448582 await view . input . press ( "End" )
449583 await view . input . pressSequentially ( ", please" )
450584 await expect ( view . input ) . toHaveText ( `${ note } , please` )
0 commit comments