Skip to content

Commit 2427b0b

Browse files
chore(casual): merge revert consistency fix (anomalyco#37751)
2 parents 8864eb5 + 2f8698b commit 2427b0b

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)