Skip to content

Commit ed66526

Browse files
chore(casual): merge revert consistency fix (anomalyco#37751)
2 parents 5556ad5 + 90b23ac commit ed66526

56 files changed

Lines changed: 6840 additions & 491 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
@@ -80,6 +80,12 @@ export type ComposerSession = {
8080
data: {
8181
location: { command: Pick<Data["location"]["command"], "list"> }
8282
session: {
83+
mutate: <T>(
84+
sessionID: string,
85+
operation: (mutation: {
86+
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
87+
}) => Promise<T>,
88+
) => Promise<T>
8389
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
8490
setStatus: Data["session"]["setStatus"]
8591
}

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

Lines changed: 129 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,48 @@ describe("Composer submission", () => {
352383
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
353384
})
354385

386+
test.each([false, true])("reserves prompt admission before later session mutations (image: %s)", async (image) => {
387+
const state = createMemoryComposerState({ prompt: "replace history" }).capture()
388+
if (image)
389+
state.set([
390+
...state.current(),
391+
{
392+
type: "image",
393+
id: "attachment",
394+
filename: "image.png",
395+
mime: "image/png",
396+
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
397+
},
398+
])
399+
const calls: string[] = []
400+
const target = session({
401+
calls,
402+
current: () => ({ agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } }),
403+
prompt: async (value) => {
404+
expect(value.files?.length ?? 0).toBe(image ? 1 : 0)
405+
},
406+
})
407+
const adapter: ActiveComposerAdapter = {
408+
kind: "active-session",
409+
state,
410+
ready: () => true,
411+
controls,
412+
working: () => false,
413+
session: () => target,
414+
interrupt: async () => undefined,
415+
submitted() {},
416+
setEditor() {},
417+
}
418+
419+
const submitted = submitInput(adapter).submit(new Event("submit"))
420+
const redo = target.data.session.mutate(target.id, async () => {
421+
calls.push("redo")
422+
})
423+
await Promise.all([submitted, redo])
424+
425+
expect(calls).toEqual(["switch-model", "prompt", "redo"])
426+
})
427+
355428
test("starts and promotes a New Session once before admitting its first prompt", async () => {
356429
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
357430
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()
@@ -610,6 +683,57 @@ describe("Composer submission", () => {
610683
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
611684
})
612685

686+
test("preserves browser comments through failed admission and same-ID retry", async () => {
687+
const state = createMemoryComposerState({ prompt: "Update this button" }).capture()
688+
const comment = {
689+
type: "browser" as const,
690+
tabID: "tab_00000000-0000-4000-8000-000000000000",
691+
url: "http://localhost:5173/",
692+
element: { selector: "#save", label: "button#save" },
693+
comment: "Rename this",
694+
}
695+
state.context.add({ ...comment, commentID: "browser-comment" })
696+
const requests: Parameters<ComposerSession["data"]["session"]["prompt"]>[0][] = []
697+
const failed = Promise.withResolvers<void>()
698+
const accepted = Promise.withResolvers<void>()
699+
const target = session({
700+
calls: [],
701+
prompt: async (value) => {
702+
requests.push(value)
703+
if (requests.length <= 2) throw new Error("network unavailable")
704+
accepted.resolve()
705+
},
706+
})
707+
const adapter: ActiveComposerAdapter = {
708+
kind: "active-session",
709+
state,
710+
ready: () => true,
711+
controls,
712+
working: () => false,
713+
session: () => target,
714+
interrupt: async () => undefined,
715+
submitted() {},
716+
setEditor() {},
717+
}
718+
const submission = submitInput(adapter, { missingSelection() {}, failed: () => failed.resolve() })
719+
720+
await submission.submit(new Event("submit"))
721+
await failed.promise
722+
expect(state.current()).toMatchObject([{ type: "text", content: "Update this button" }])
723+
expect(state.context.items()).toMatchObject([{ ...comment, commentID: "browser-comment" }])
724+
725+
await submission.submit(new Event("submit"))
726+
await accepted.promise
727+
expect(requests).toHaveLength(3)
728+
expect(new Set(requests.map((request) => request.id)).size).toBe(1)
729+
requests.forEach((request) => {
730+
expect(request.metadata?.comments).toEqual([comment])
731+
expect(request.text).toContain('selector "#save"')
732+
expect(request.text).toContain("Rename this")
733+
})
734+
expect(state.context.items()).toEqual([])
735+
})
736+
613737
test("forwards structured mentions to custom commands", async () => {
614738
const state = createMemoryComposerState().capture()
615739
state.set([

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

Lines changed: 49 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -88,22 +88,26 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
8888
return
8989
}
9090
if (submitting.has(input.adapter.state)) return
91-
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
92-
const value = {
93-
...read,
94-
images: await Promise.all(
95-
read.images.map(async (image) => ({
96-
...image,
97-
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
98-
})),
99-
),
100-
}
10191
submitting.add(input.adapter.state)
10292
const comments = input.comments.capture()
10393
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
104-
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
94+
const command = read.mode === "normal" ? findCommand(input.commands(), read.text) : undefined
10595

10696
try {
97+
// Only new sessions need image URLs for the handoff preview. Existing sessions
98+
// load image bytes inside sendPrompt's reservation so a revert cannot overtake them.
99+
const value =
100+
input.adapter.kind === "new-session" && read.images.length
101+
? {
102+
...read,
103+
images: await Promise.all(
104+
read.images.map(async (image) => ({
105+
...image,
106+
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
107+
})),
108+
),
109+
}
110+
: read
107111
const started =
108112
input.adapter.kind === "active-session"
109113
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
@@ -120,6 +124,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
120124
const optimisticBusy = !input.adapter.working()
121125
if (optimisticBusy && input.adapter.kind === "new-session")
122126
session.data.session.setStatus(session.id, "running")
127+
// Reserve before any await so a later revert or prompt cannot overtake this admission.
123128
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
124129
if (optimisticBusy && input.adapter.kind === "active-session")
125130
session.data.session.setStatus(session.id, "running")
@@ -401,44 +406,46 @@ async function applySelection(
401406
}
402407
}
403408

404-
async function sendPrompt(
409+
function sendPrompt(
405410
session: ComposerSession,
406411
value: ComposerSubmission,
407412
track: ModelSelection["trackSessionCommit"] | undefined,
408413
onAdmit: () => void,
409414
) {
410-
const request = await buildSubmissionRequest(session, value)
411-
// Switching agent or model reconfigures the session immediately, and with it
412-
// the remainder of a running turn. A steer targets that turn, so its
413-
// selection applies now; a queued follow-up must not reconfigure the turn it
414-
// waits behind, so it runs with the session selection at delivery time (the
415-
// intended selection stays recorded in its metadata).
416-
if (value.delivery === "steer") {
417-
await applySelection(session, value.selection, track)
418-
}
415+
return session.data.session.mutate(session.id, async (mutation) => {
416+
const request = await buildSubmissionRequest(session, value)
417+
// Switching agent or model reconfigures the session immediately, and with it
418+
// the remainder of a running turn. A steer targets that turn, so its
419+
// selection applies now; a queued follow-up must not reconfigure the turn it
420+
// waits behind, so it runs with the session selection at delivery time (the
421+
// intended selection stays recorded in its metadata).
422+
if (value.delivery === "steer") {
423+
await applySelection(session, value.selection, track)
424+
}
419425

420-
const admission = {
421-
id: value.id,
422-
sessionID: session.id,
423-
delivery: value.delivery,
424-
text: request.text,
425-
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
426-
agents: request.agents,
427-
skills: request.skills,
428-
metadata: {
429-
displayText: request.displayText,
430-
comments: request.comments,
431-
attachments: request.attachments,
432-
agent: value.selection.agent,
433-
model: {
434-
...value.selection.model,
435-
...(value.selection.variant ? { variant: value.selection.variant } : {}),
426+
const admission = {
427+
id: value.id,
428+
sessionID: session.id,
429+
delivery: value.delivery,
430+
text: request.text,
431+
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
432+
agents: request.agents,
433+
skills: request.skills,
434+
metadata: {
435+
displayText: request.displayText,
436+
comments: request.comments,
437+
attachments: request.attachments,
438+
agent: value.selection.agent,
439+
model: {
440+
...value.selection.model,
441+
...(value.selection.variant ? { variant: value.selection.variant } : {}),
442+
},
436443
},
437-
},
438-
}
439-
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
440-
onAdmit()
441-
await sending
444+
}
445+
const sending = mutation.prompt(admission)
446+
onAdmit()
447+
await sending
448+
})
442449
}
443450

444451
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,

0 commit comments

Comments
 (0)