Skip to content

Commit 050932f

Browse files
fix: preserve revert consistency
Park execution while a revert is staged and serialize revert mutations with prompt and synthetic admission. Commit replacement prompts, changed selection, and optional context atomically with the revert, retaining pending input before its chronological boundary. Apply selection intent only to winning steer admissions. Keep queued requests and same-ID retries from reapplying selection, share the short admission lock with explicit switches, and expose intent to prompt hooks without claiming it is already committed. 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. Do not hold the input FIFO through shell completion or certify agent selection from reconciled admission receipts. Keep pending start-snapshot readiness interruptible when a provider fails before first assistant publication, so Stop can release execution and subsequent revert staging. Retain uninterruptible final step/tool cleanup. Bind delayed undo to its captured Session and paginated chronological history, and preserve queue rollback and full-clear redo. Regenerate the public clients and API documents from the prompt schema. Closes anomalyco#37751, anomalyco#44357
1 parent 5cfe3b4 commit 050932f

61 files changed

Lines changed: 6559 additions & 628 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: 136 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

@@ -14,6 +14,7 @@ const sessionID = "ses_session_queue_regression"
1414
const 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+
226359
test("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`)

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,11 @@ export type ComposerSession = {
6868
switchModel: (
6969
input: Parameters<ServerSDK["api"]["session"]["switchModel"]>[0],
7070
) => ReturnType<ServerSDK["api"]["session"]["switchModel"]>
71-
revert: {
72-
commit: (
73-
input: Parameters<ServerSDK["api"]["session"]["revert"]["commit"]>[0],
74-
) => ReturnType<ServerSDK["api"]["session"]["revert"]["commit"]>
75-
}
7671
}
7772
data: {
7873
location: { command: Pick<Data["location"]["command"], "list"> }
7974
session: {
75+
mutate: Data["session"]["mutate"]
8076
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => ReturnType<Data["session"]["prompt"]>
8177
setStatus: Data["session"]["setStatus"]
8278
}

0 commit comments

Comments
 (0)