Skip to content

Commit 2f8698b

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 while preserving optimistic display and same-ID retries; resolve undo boundaries by transcript position rather than message ID. Closes anomalyco#37751, anomalyco#44357
1 parent 8864eb5 commit 2f8698b

56 files changed

Lines changed: 5567 additions & 449 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-message-revert.spec.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,44 @@ test("hides revert actions in a child session", async ({ page }) => {
9595
await message.hover()
9696
await expect(message.getByRole("button", { name: "Revert message" })).toHaveCount(0)
9797
})
98+
99+
test("keeps pending input when staging and replacing a revert", async ({ page }) => {
100+
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
101+
const prompts: Record<string, unknown>[] = []
102+
await mockOpenCodeServer(page, {
103+
...fixture,
104+
sessions: [session],
105+
inbox: [
106+
{
107+
id: "msg_pending_before_boundary",
108+
sessionID,
109+
time: { created: 2 },
110+
type: "user",
111+
delivery: "queue",
112+
payload: { text: "Keep this queued input" },
113+
},
114+
],
115+
onInboxChange: (input) => changes.push({ inboxID: input.inboxID, action: input.action }),
116+
onPrompt: (input) => prompts.push(input.body),
117+
})
118+
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
119+
await expectSessionTitle(page, "Session message revert")
120+
const pending = page.locator('[data-component="session-queue-row"]').filter({ hasText: "Keep this queued input" })
121+
await expect(pending).toBeVisible()
122+
const message = page.locator('[data-message-id="msg_second"]')
123+
await message.hover()
124+
await message.getByRole("button", { name: "Revert message" }).click()
125+
const prompt = page.getByRole("textbox", { name: "Prompt" })
126+
await expect(prompt).toHaveText("Second prompt")
127+
await prompt.fill("Replacement prompt")
128+
const admitted = page.waitForResponse(
129+
(response) =>
130+
response.request().method() === "POST" && new URL(response.url()).pathname === `/api/session/${sessionID}/prompt`,
131+
)
132+
await prompt.press("Enter")
133+
expect((await admitted).ok()).toBe(true)
134+
await expect(prompt).toHaveText("")
135+
await expect(pending).toBeVisible()
136+
expect(prompts).toMatchObject([{ text: "Replacement prompt" }])
137+
expect(changes).toEqual([])
138+
})

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ export type ComposerSession = {
7575
data: {
7676
location: { command: Pick<Data["location"]["command"], "list"> }
7777
session: {
78+
mutate: <T>(
79+
sessionID: string,
80+
operation: (mutation: {
81+
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
82+
}) => Promise<T>,
83+
) => Promise<T>
7884
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
7985
setStatus: Data["session"]["setStatus"]
8086
}

‎packages/app/src/composer/submit.test.ts‎

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { describe, expect, test } from "bun:test"
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { createRoot } from "solid-js"
3+
import { OpenCode } from "@opencode/client/promise"
4+
import { createData } from "@opencode/client/solid"
25
import type { ModelSelection } from "@/providers/models/selection"
36
import type { SessionMessageUser } from "@opencode/client/promise"
47
import { Skill } from "@opencode/schema/skill"
58
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
69
import { createMemoryComposerState } from "./state"
710
import { createComposerSubmit } from "./submit"
811

12+
const disposers: Array<() => void> = []
13+
afterEach(() => disposers.splice(0).forEach((dispose) => dispose()))
14+
915
const selectedModel = {
1016
id: "model-1",
1117
name: "Model 1",
@@ -87,6 +93,33 @@ function session(input: {
8793
switchAgent?: ComposerSession["api"]["switchAgent"]
8894
switchModel?: ComposerSession["api"]["switchModel"]
8995
}): ComposerSession {
96+
const api = OpenCode.make({ baseUrl: "http://opencode.test" })
97+
const data = createRoot((dispose) => {
98+
disposers.push(dispose)
99+
return createData({
100+
directory: "C:/repo",
101+
event: { on: () => () => {}, listen: () => () => {} },
102+
api: () => ({
103+
...api,
104+
session: {
105+
...api.session,
106+
prompt: async (value) => {
107+
if (!value.id) throw new Error("Client admission must supply a message ID")
108+
input.calls.push("prompt")
109+
await input.prompt(value)
110+
return {
111+
id: value.id,
112+
sessionID: value.sessionID,
113+
time: { created: Date.now() },
114+
type: "user",
115+
delivery: value.delivery ?? "steer",
116+
payload: { text: value.text },
117+
}
118+
},
119+
},
120+
}),
121+
})
122+
})
90123
return {
91124
id: "session-1",
92125
directory: "C:/repo",
@@ -111,10 +144,8 @@ function session(input: {
111144
location: { command: { list: () => [] } },
112145
session: {
113146
setStatus: (_sessionID, status) => input.statuses?.push(status),
114-
prompt: async (value) => {
115-
input.calls.push("prompt")
116-
await input.prompt(value)
117-
},
147+
mutate: data.session.mutate,
148+
prompt: data.session.prompt,
118149
},
119150
},
120151
}
@@ -352,6 +383,35 @@ describe("Composer submission", () => {
352383
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
353384
})
354385

386+
test("reserves prompt admission before later session mutations", async () => {
387+
const state = createMemoryComposerState({ prompt: "replace history" }).capture()
388+
const calls: string[] = []
389+
const target = session({
390+
calls,
391+
current: () => ({ agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } }),
392+
prompt: async () => undefined,
393+
})
394+
const adapter: ActiveComposerAdapter = {
395+
kind: "active-session",
396+
state,
397+
ready: () => true,
398+
controls,
399+
working: () => false,
400+
session: () => target,
401+
interrupt: async () => undefined,
402+
submitted() {},
403+
setEditor() {},
404+
}
405+
406+
const submitted = submitInput(adapter).submit(new Event("submit"))
407+
const redo = target.data.session.mutate(target.id, async () => {
408+
calls.push("redo")
409+
})
410+
await Promise.all([submitted, redo])
411+
412+
expect(calls).toEqual(["switch-model", "prompt", "redo"])
413+
})
414+
355415
test("starts and promotes a New Session once before admitting its first prompt", async () => {
356416
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
357417
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()

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

Lines changed: 46 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,18 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
8585
}
8686
if (submitting.has(input.adapter.state)) return
8787
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
88-
const value = {
89-
...read,
90-
images: await Promise.all(
91-
read.images.map(async (image) => ({
92-
...image,
93-
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
94-
})),
95-
),
96-
}
88+
const value =
89+
read.images.length === 0
90+
? read
91+
: {
92+
...read,
93+
images: await Promise.all(
94+
read.images.map(async (image) => ({
95+
...image,
96+
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
97+
})),
98+
),
99+
}
97100
submitting.add(input.adapter.state)
98101
const comments = input.comments.capture()
99102
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
@@ -116,6 +119,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
116119
const optimisticBusy = !input.adapter.working()
117120
if (optimisticBusy && input.adapter.kind === "new-session")
118121
session.data.session.setStatus(session.id, "running")
122+
// Reserve before any await so a later revert or prompt cannot overtake this admission.
119123
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
120124
if (optimisticBusy && input.adapter.kind === "active-session")
121125
session.data.session.setStatus(session.id, "running")
@@ -374,44 +378,46 @@ async function applySelection(
374378
}
375379
}
376380

377-
async function sendPrompt(
381+
function sendPrompt(
378382
session: ComposerSession,
379383
value: ComposerSubmission,
380384
track: ModelSelection["trackSessionCommit"] | undefined,
381385
onAdmit: () => void,
382386
) {
383-
const request = await buildSubmissionRequest(session, value)
384-
// Switching agent or model reconfigures the session immediately, and with it
385-
// the remainder of a running turn. A steer targets that turn, so its
386-
// selection applies now; a queued follow-up must not reconfigure the turn it
387-
// waits behind, so it runs with the session selection at delivery time (the
388-
// intended selection stays recorded in its metadata).
389-
if (value.delivery === "steer") {
390-
await applySelection(session, value.selection, track)
391-
}
387+
return session.data.session.mutate(session.id, async (mutation) => {
388+
const request = await buildSubmissionRequest(session, value)
389+
// Switching agent or model reconfigures the session immediately, and with it
390+
// the remainder of a running turn. A steer targets that turn, so its
391+
// selection applies now; a queued follow-up must not reconfigure the turn it
392+
// waits behind, so it runs with the session selection at delivery time (the
393+
// intended selection stays recorded in its metadata).
394+
if (value.delivery === "steer") {
395+
await applySelection(session, value.selection, track)
396+
}
392397

393-
const admission = {
394-
id: value.id,
395-
sessionID: session.id,
396-
delivery: value.delivery,
397-
text: request.text,
398-
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
399-
agents: request.agents,
400-
skills: request.skills,
401-
metadata: {
402-
displayText: request.displayText,
403-
comments: request.comments,
404-
attachments: request.attachments,
405-
agent: value.selection.agent,
406-
model: {
407-
...value.selection.model,
408-
...(value.selection.variant ? { variant: value.selection.variant } : {}),
398+
const admission = {
399+
id: value.id,
400+
sessionID: session.id,
401+
delivery: value.delivery,
402+
text: request.text,
403+
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
404+
agents: request.agents,
405+
skills: request.skills,
406+
metadata: {
407+
displayText: request.displayText,
408+
comments: request.comments,
409+
attachments: request.attachments,
410+
agent: value.selection.agent,
411+
model: {
412+
...value.selection.model,
413+
...(value.selection.variant ? { variant: value.selection.variant } : {}),
414+
},
409415
},
410-
},
411-
}
412-
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
413-
onAdmit()
414-
await sending
416+
}
417+
const sending = mutation.prompt(admission)
418+
onAdmit()
419+
await sending
420+
})
415421
}
416422

417423
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {

‎packages/app/src/new-session/composer-adapter.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ export function createNewSessionComposerAdapter(props: {
152152
location: data.location,
153153
session: {
154154
setStatus: data.session.setStatus,
155+
mutate: (sessionID, operation) =>
156+
data.session.mutate(sessionID, (mutation) =>
157+
operation({
158+
prompt: (input) =>
159+
mutation.prompt({
160+
...input,
161+
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
162+
}),
163+
}),
164+
),
155165
prompt: (input) =>
156166
data.session.prompt({
157167
...input,

‎packages/app/src/session/composer/queue.ts‎

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,31 @@ export function createSessionQueue(input: {
5353
delivery: ComposerDelivery
5454
},
5555
) => {
56-
if (change.type === "reorder") return rewrite(change.inboxIDs)
57-
const replacement = await editedPromptInput(
58-
input.sessionID,
59-
location().directory,
60-
change.item,
61-
change.prompt,
62-
change.text,
63-
)
64-
// Admit before cancelling so a failed replacement never discards the original.
65-
const admitted = await data.session.prompt({
66-
...replacement,
67-
id: change.replacement,
68-
delivery: change.delivery,
69-
...(change.delivery === "queue" ? { resume: false } : {}),
56+
if (change.type === "reorder")
57+
return data.session.mutate(input.sessionID, (reservation) => rewrite(change.inboxIDs, reservation.prompt))
58+
return data.session.mutate(input.sessionID, async (reservation) => {
59+
const replacement = await editedPromptInput(
60+
input.sessionID,
61+
location().directory,
62+
change.item,
63+
change.prompt,
64+
change.text,
65+
)
66+
// Admit before cancelling so a failed replacement never discards the original.
67+
const admitted = await reservation.prompt({
68+
...replacement,
69+
id: change.replacement,
70+
delivery: change.delivery,
71+
...(change.delivery === "queue" ? { resume: false } : {}),
72+
})
73+
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
74+
cancelEdit()
75+
if (change.delivery === "queue")
76+
await rewrite(
77+
change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)),
78+
reservation.prompt,
79+
)
7080
})
71-
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
72-
cancelEdit()
73-
if (change.delivery === "queue")
74-
await rewrite(change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)))
7581
},
7682
onError: notify,
7783
onSettled: () => data.session.pending.sync(input.sessionID).catch(() => undefined),
@@ -97,7 +103,7 @@ export function createSessionQueue(input: {
97103
})
98104
onCleanup(() => cancelEdit())
99105

100-
const rewrite = async (inboxIDs: string[]) => {
106+
const rewrite = async (inboxIDs: string[], admit: typeof data.session.prompt) => {
101107
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
102108
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
103109
throw new Error("Queued control items block reordering")
@@ -109,7 +115,7 @@ export function createSessionQueue(input: {
109115

110116
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix.
111117
for (const item of ordered.slice(changed)) {
112-
await data.session.prompt({
118+
await admit({
113119
sessionID: input.sessionID,
114120
text: item.payload.text,
115121
files: item.payload.files?.map((file) => ({

‎packages/app/src/session/model.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ describe("session controller invariants", () => {
3939
})
4040

4141
test("selects user history strictly before the revert boundary", () => {
42-
const messages: SessionMessageInfo[] = [user("msg_a"), assistant, user("msg_b"), user("msg_c")]
42+
const messages: SessionMessageInfo[] = [user("msg_fff"), assistant, user("msg_000"), user("msg_zzz")]
4343
const users = selectSessionUserMessages(messages)
4444

45-
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
46-
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
47-
expect(selectVisibleSessionUserMessages(users.slice(2), "msg_b")).toEqual([])
45+
expect(users.map((message) => message.id)).toEqual(["msg_fff", "msg_000", "msg_zzz"])
46+
expect(selectVisibleSessionUserMessages(users, "msg_000").map((message) => message.id)).toEqual(["msg_fff"])
47+
expect(selectVisibleSessionUserMessages(users.slice(2), "msg_000")).toEqual([])
4848
expect(selectVisibleSessionUserMessages(users)).toBe(users)
4949
})
5050

0 commit comments

Comments
 (0)