Skip to content

Commit 3eac862

Browse files
fix: preserve revert consistency
Keep execution parked while a revert is staged and serialize revert mutations with prompt and synthetic admission. Commit replacement prompts and their optional context atomically with the revert, retaining queued input before its chronological boundary. Keep projected reads in SessionStore and scope plugin setup and hook admission bypasses to their active invocation. Serialize client mutations without delaying optimistic display or releasing same-ID retries early. Bind delayed undo to its captured Session and history, resolve paginated boundaries chronologically, and preserve queue rollback and full-clear redo. Closes anomalyco#37751, anomalyco#44357
1 parent e86ec98 commit 3eac862

56 files changed

Lines changed: 8197 additions & 787 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/app/e2e/regression/session-header.spec.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { SessionMessageInfo } from "@opencode/client/promise"
22
import { expect, test, type Page } from "@playwright/test"
3-
import { seed, sessionHref } from "../utils/app"
3+
import { pageMessagesFrom, seed, sessionHref } from "../utils/app"
44
import { fixture, mockStressTimeline } from "../utils/session-fixture"
55
import { expectSessionTitle } from "../utils/waits"
66
import { mockWorkspace, openSession } from "../utils/workspace"
@@ -327,6 +327,35 @@ test.describe("revert", () => {
327327
expect(staged).toEqual([])
328328
})
329329

330+
test("undo loads the staged boundary from an older page", async ({ page }) => {
331+
const sessionID = "ses_paginated_revert"
332+
const previous = { id: "msg_fff_previous", type: "user" as const, text: "Previous prompt", time: { created: 1 } }
333+
const history = [
334+
previous,
335+
{ id: "msg_000_boundary", type: "user" as const, text: "Boundary prompt", time: { created: 2 } },
336+
...Array.from({ length: 200 }, (_, index) => ({
337+
id: `msg_after_${index}`,
338+
type: "user" as const,
339+
text: "Reverted prompt",
340+
time: { created: index + 3 },
341+
})),
342+
]
343+
const staged: { sessionID: string; messageID: string }[] = []
344+
const { editor } = await openSession(page, {
345+
name: "PaginatedRevert",
346+
sessions: [{ id: sessionID, title: "Paginated revert", revert: { messageID: "msg_000_boundary" } }],
347+
pageMessages: pageMessagesFrom({ [sessionID]: history }),
348+
onRevertStage: (input) => staged.push(input),
349+
})
350+
await editor.pressSequentially("/undo")
351+
await expect(
352+
page.locator('[data-component="composer-suggestions"] [data-suggestion-id][data-active]'),
353+
).toContainText("/undo")
354+
await editor.press("Enter")
355+
await expect(editor).toHaveText("Previous prompt")
356+
expect(staged).toEqual([{ sessionID, messageID: previous.id }])
357+
})
358+
330359
test("hides revert actions in a child session", async ({ page }) => {
331360
await mockWorkspace(page, {
332361
...workspace,

‎packages/app/e2e/regression/session-queue.spec.ts‎

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { PromptInput } from "@opencode/schema/prompt-input"
44
import { SessionInbox } from "@opencode/schema/session-inbox"
55
import { SessionMessage } from "@opencode/schema/session-message"
66
import { Schema } from "effect"
7-
import { provider } from "../utils/app"
7+
import { holdRoute, provider, sessionHref } from "../utils/app"
88
import type { MockServerConfig } from "../utils/mock-server"
99
import { 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+
226356
test("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
)

‎packages/app/src/composer/adapter.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export type ComposerSession = {
6464
data: {
6565
location: { command: Pick<Data["location"]["command"], "list"> }
6666
session: {
67+
mutate: <T>(
68+
sessionID: string,
69+
operation: (mutation: {
70+
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
71+
}) => Promise<T>,
72+
) => Promise<T>
6773
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
6874
setStatus: Data["session"]["setStatus"]
6975
}

0 commit comments

Comments
 (0)