Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/app/e2e/regression/session-message-revert.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,44 @@ test("hides revert actions in a child session", async ({ page }) => {
await message.hover()
await expect(message.getByRole("button", { name: "Revert message" })).toHaveCount(0)
})

test("keeps pending input when staging and replacing a revert", async ({ page }) => {
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const prompts: Record<string, unknown>[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions: [session],
inbox: [
{
id: "msg_pending_before_boundary",
sessionID,
time: { created: 2 },
type: "user",
delivery: "queue",
payload: { text: "Keep this queued input" },
},
],
onInboxChange: (input) => changes.push({ inboxID: input.inboxID, action: input.action }),
onPrompt: (input) => prompts.push(input.body),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Session message revert")
const pending = page.locator('[data-component="session-queue-row"]').filter({ hasText: "Keep this queued input" })
await expect(pending).toBeVisible()
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
await message.getByRole("button", { name: "Revert message" }).click()
const prompt = page.getByRole("textbox", { name: "Prompt" })
await expect(prompt).toHaveText("Second prompt")
await prompt.fill("Replacement prompt")
const admitted = page.waitForResponse(
(response) =>
response.request().method() === "POST" && new URL(response.url()).pathname === `/api/session/${sessionID}/prompt`,
)
await prompt.press("Enter")
expect((await admitted).ok()).toBe(true)
await expect(prompt).toHaveText("")
await expect(pending).toBeVisible()
expect(prompts).toMatchObject([{ text: "Replacement prompt" }])
expect(changes).toEqual([])
})
6 changes: 6 additions & 0 deletions packages/app/src/composer/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export type ComposerSession = {
data: {
location: { command: Pick<Data["location"]["command"], "list"> }
session: {
mutate: <T>(
sessionID: string,
operation: (mutation: {
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
}) => Promise<T>,
) => Promise<T>
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
setStatus: Data["session"]["setStatus"]
}
Expand Down
134 changes: 129 additions & 5 deletions packages/app/src/composer/submit.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { OpenCode } from "@opencode/client/promise"
import { createData } from "@opencode/client/solid"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"

const disposers: Array<() => void> = []
afterEach(() => disposers.splice(0).forEach((dispose) => dispose()))

const selectedModel = {
id: "model-1",
name: "Model 1",
Expand Down Expand Up @@ -87,6 +93,33 @@ function session(input: {
switchAgent?: ComposerSession["api"]["switchAgent"]
switchModel?: ComposerSession["api"]["switchModel"]
}): ComposerSession {
const api = OpenCode.make({ baseUrl: "http://opencode.test" })
const data = createRoot((dispose) => {
disposers.push(dispose)
return createData({
directory: "C:/repo",
event: { on: () => () => {}, listen: () => () => {} },
api: () => ({
...api,
session: {
...api.session,
prompt: async (value) => {
if (!value.id) throw new Error("Client admission must supply a message ID")
input.calls.push("prompt")
await input.prompt(value)
return {
id: value.id,
sessionID: value.sessionID,
time: { created: Date.now() },
type: "user",
delivery: value.delivery ?? "steer",
payload: { text: value.text },
}
},
},
}),
})
})
return {
id: "session-1",
directory: "C:/repo",
Expand All @@ -111,10 +144,8 @@ function session(input: {
location: { command: { list: () => [] } },
session: {
setStatus: (_sessionID, status) => input.statuses?.push(status),
prompt: async (value) => {
input.calls.push("prompt")
await input.prompt(value)
},
mutate: data.session.mutate,
prompt: data.session.prompt,
},
},
}
Expand Down Expand Up @@ -352,6 +383,48 @@ describe("Composer submission", () => {
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})

test.each([false, true])("reserves prompt admission before later session mutations (image: %s)", async (image) => {
const state = createMemoryComposerState({ prompt: "replace history" }).capture()
if (image)
state.set([
...state.current(),
{
type: "image",
id: "attachment",
filename: "image.png",
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
])
const calls: string[] = []
const target = session({
calls,
current: () => ({ agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } }),
prompt: async (value) => {
expect(value.files?.length ?? 0).toBe(image ? 1 : 0)
},
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}

const submitted = submitInput(adapter).submit(new Event("submit"))
const redo = target.data.session.mutate(target.id, async () => {
calls.push("redo")
})
await Promise.all([submitted, redo])

expect(calls).toEqual(["switch-model", "prompt", "redo"])
})

test("starts and promotes a New Session once before admitting its first prompt", async () => {
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()
Expand Down Expand Up @@ -610,6 +683,57 @@ describe("Composer submission", () => {
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
})

test("preserves browser comments through failed admission and same-ID retry", async () => {
const state = createMemoryComposerState({ prompt: "Update this button" }).capture()
const comment = {
type: "browser" as const,
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/",
element: { selector: "#save", label: "button#save" },
comment: "Rename this",
}
state.context.add({ ...comment, commentID: "browser-comment" })
const requests: Parameters<ComposerSession["data"]["session"]["prompt"]>[0][] = []
const failed = Promise.withResolvers<void>()
const accepted = Promise.withResolvers<void>()
const target = session({
calls: [],
prompt: async (value) => {
requests.push(value)
if (requests.length <= 2) throw new Error("network unavailable")
accepted.resolve()
},
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
const submission = submitInput(adapter, { missingSelection() {}, failed: () => failed.resolve() })

await submission.submit(new Event("submit"))
await failed.promise
expect(state.current()).toMatchObject([{ type: "text", content: "Update this button" }])
expect(state.context.items()).toMatchObject([{ ...comment, commentID: "browser-comment" }])

await submission.submit(new Event("submit"))
await accepted.promise
expect(requests).toHaveLength(3)
expect(new Set(requests.map((request) => request.id)).size).toBe(1)
requests.forEach((request) => {
expect(request.metadata?.comments).toEqual([comment])
expect(request.text).toContain('selector "#save"')
expect(request.text).toContain("Rename this")
})
expect(state.context.items()).toEqual([])
})

test("forwards structured mentions to custom commands", async () => {
const state = createMemoryComposerState().capture()
state.set([
Expand Down
91 changes: 49 additions & 42 deletions packages/app/src/composer/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,26 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
return
}
if (submitting.has(input.adapter.state)) return
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
const value = {
...read,
images: await Promise.all(
read.images.map(async (image) => ({
...image,
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
})),
),
}
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
const command = read.mode === "normal" ? findCommand(input.commands(), read.text) : undefined

try {
// Only new sessions need image URLs for the handoff preview. Existing sessions
// load image bytes inside sendPrompt's reservation so a revert cannot overtake them.
const value =
input.adapter.kind === "new-session" && read.images.length
? {
...read,
images: await Promise.all(
read.images.map(async (image) => ({
...image,
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
})),
),
}
: read
const started =
input.adapter.kind === "active-session"
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
Expand All @@ -120,6 +124,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const optimisticBusy = !input.adapter.working()
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
// Reserve before any await so a later revert or prompt cannot overtake this admission.
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
Expand Down Expand Up @@ -401,44 +406,46 @@ async function applySelection(
}
}

async function sendPrompt(
function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
await applySelection(session, value.selection, track)
}
return session.data.session.mutate(session.id, async (mutation) => {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
await applySelection(session, value.selection, track)
}

const admission = {
id: value.id,
sessionID: session.id,
delivery: value.delivery,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
metadata: {
displayText: request.displayText,
comments: request.comments,
attachments: request.attachments,
agent: value.selection.agent,
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
const admission = {
id: value.id,
sessionID: session.id,
delivery: value.delivery,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
metadata: {
displayText: request.displayText,
comments: request.comments,
attachments: request.attachments,
agent: value.selection.agent,
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
},
},
}
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
onAdmit()
await sending
}
const sending = mutation.prompt(admission)
onAdmit()
await sending
})
}

async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/new-session/composer-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ export function createNewSessionComposerAdapter(props: {
location: data.location,
session: {
setStatus: data.session.setStatus,
mutate: (sessionID, operation) =>
data.session.mutate(sessionID, (mutation) =>
operation({
prompt: (input) =>
mutation.prompt({
...input,
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
}),
}),
),
prompt: (input) =>
data.session.prompt({
...input,
Expand Down
Loading
Loading