import { expect, test, type Page } from "@playwright/test" import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise" import { provider } from "../utils/app" import { openSession } from "../utils/workspace" const sessionID = "ses_session_queue_regression" type InboxRow = { id: string sessionID: string time: { created: number } type: "user" payload: { text: string metadata?: Record files?: Extract["payload"]["files"] agents?: Extract["payload"]["agents"] } delivery: "steer" | "queue" } function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) { const rows: InboxRow[] = seed.map((text, index) => ({ id: `inb_seed_${index + 1}`, sessionID, time: { created: 1700000000000 + index }, type: "user", payload: { text }, delivery: "queue", })) const events: OpenCodeEvent[] = [] const prompts: Record[] = [] const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = [] const log: string[] = [] let sequence = 0 const emit = ( type: Type, data: Extract["data"], ) => { sequence += 1 events.push({ id: `evt_queue_${sequence}`, type, created: Date.now(), durable: { aggregateID: sessionID, seq: sequence, version: type === "session.tool.success" ? 2 : 1 }, data, } as OpenCodeEvent) } return { rows, prompts, changes, log, messages, emit, events: () => events.splice(0), onPrompt: (input: { sessionID: string; body: Record }) => { prompts.push(input.body) log.push(`prompt:${String(input.body.delivery ?? "steer")}`) const row: InboxRow = { id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`, sessionID: input.sessionID, time: { created: Date.now() }, type: "user", payload: { text: typeof input.body.text === "string" ? input.body.text : "", ...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record }), }, delivery: input.body.delivery === "queue" ? "queue" : "steer", } rows.push(row) emit("session.inbox.enqueued", { sessionID: input.sessionID, inboxID: row.id, item: { type: "user", payload: row.payload, delivery: row.delivery }, }) }, onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => { changes.push({ inboxID: input.inboxID, action: input.action }) log.push(`${input.action}:${input.inboxID}`) const index = rows.findIndex((row) => row.id === input.inboxID) const row = rows[index] if (!row) return if (input.action === "cancel") { rows.splice(index, 1) emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID }) return } row.delivery = input.action emit("session.inbox.delivery.changed", { sessionID: input.sessionID, inboxID: input.inboxID, delivery: input.action, }) }, } } async function openQueue(page: Page, mock: ReturnType, followUpBehavior?: "queue" | "steer") { const model = { id: "queue-model", name: "Queue Model" } await openSession(page, { name: "SessionQueueRegression", sessions: [{ id: sessionID, title: "Session queue regression", model: { id: model.id, providerID: "opencode" } }], provider: provider(model), pageMessages: () => ({ items: mock.messages }), sessionStatus: () => ({ [sessionID]: { type: "running" } }), inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })), onPrompt: mock.onPrompt, onInboxChange: mock.onInboxChange, events: mock.events, ...(followUpBehavior ? { seed: { settings: { general: { followUpBehavior } } } } : {}), }) const composer = page.locator('[data-component="composer"]') return { composer, input: composer.locator('[data-component="composer-editor"]'), rows: page.locator('[data-component="session-queue-row"]'), } } test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => { const mock = createQueueMock([]) const view = await openQueue(page, mock, "queue") await view.input.fill("queue this follow-up") await expect(view.composer.locator('[data-action="composer-alternate-delivery"]')).toContainText("Steer") await view.input.press("Enter") await expect(view.rows.getByText("queue this follow-up", { exact: true })).toBeVisible() await view.input.fill("steer this correction") await view.input.press("ControlOrMeta+Enter") await expect.poll(() => mock.prompts.map((prompt) => prompt.delivery)).toEqual(["queue", "steer"]) await expect(view.input).toHaveText("") }) test("dragging reorders queued prompts", async ({ page }) => { const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"]) const view = await openQueue(page, mock) await expect(view.rows).toHaveCount(3) const first = view.rows.filter({ hasText: "first queued prompt" }) const third = view.rows.filter({ hasText: "third queued prompt" }) await first.getByRole("button", { name: "Reorder queued prompt" }).hover() await page.mouse.down() const target = await third.boundingBox() if (!target) throw new Error("The target queue row is not visible") await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 }) await page.mouse.up() await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([ "second queued prompt", "third queued prompt", "first queued prompt", ]) expect(mock.prompts.map((prompt) => prompt.text)).toEqual([ "second queued prompt", "third queued prompt", "first queued prompt", ]) expect(mock.changes).toEqual([ { inboxID: "inb_seed_1", action: "cancel" }, { inboxID: "inb_seed_2", action: "cancel" }, { inboxID: "inb_seed_3", action: "cancel" }, ]) }) test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => { const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"]) const view = await openQueue(page, mock) const original = view.rows.getByText("tighten the error copy", { exact: true }) await expect(original).toBeVisible() await view.input.fill("my in-progress draft") await original.click() await expect(view.input).toHaveText("tighten the error copy") await expect(view.input).toBeFocused() await view.input.press("Escape") await expect(view.input).toHaveText("my in-progress draft") await original.click() await expect(view.input).toHaveText("tighten the error copy") await expect(view.input).toBeFocused() await view.input.fill("tighten the error copy and add a retry hint") await expect(view.input).toHaveText("tighten the error copy and add a retry hint") await view.input.press("Enter") await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([ "first queued prompt", "tighten the error copy and add a retry hint", "third queued prompt", ]) await expect(view.input).toHaveText("my in-progress draft") expect(mock.prompts.map((prompt) => prompt.text)).toEqual([ "tighten the error copy and add a retry hint", "tighten the error copy and add a retry hint", "third queued prompt", ]) expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true) expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"]) expect(mock.log[0]).toBe("prompt:queue") }) test("Undo cancels only the selected queued prompt and focuses the restored input", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) const text = "Review the detailed error report and check every step of the retry path ".repeat(4) const rest = ["first queued prompt", ...Array.from({ length: 5 }, (_, index) => `queued follow-up ${index + 1}`)] const mock = createQueueMock([...rest.slice(0, 1), text, ...rest.slice(1)]) const view = await openQueue(page, mock) await expect(view.rows).toHaveCount(7) // A long prompt in a long queue on a narrow screen keeps its icon-only Undo usable. const undo = view.rows.filter({ hasText: text }).getByRole("button", { name: "Undo" }) await expect(undo).toHaveText("") await undo.hover() await expect(page.getByRole("tooltip")).toHaveText("Undo") await undo.click() await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText(rest) await expect(view.input).toHaveText(text) await expect(view.input).toBeFocused() expect(mock.changes).toEqual([{ inboxID: "inb_seed_2", action: "cancel" }]) expect(mock.prompts).toEqual([]) }) test("Undo appends to an existing draft and restores inline attachments", async ({ page }) => { const mock = createQueueMock(["queued with image"]) mock.rows[0].payload.files = [ { data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL/nwAAAABJRU5ErkJggg==", mime: "image/png", source: { type: "inline" }, name: "shot.png", }, ] const view = await openQueue(page, mock) await view.input.fill("my draft") await view.rows.getByRole("button", { name: "Undo" }).click() await expect(view.rows).toHaveCount(0) await expect(view.input).toHaveText("my draft\n\nqueued with image") await expect(view.input).toBeFocused() await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible() expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }]) }) test("Undo preserves mentioned file and agent references on resubmission", async ({ page }) => { const mock = createQueueMock(["inspect @main.ts with @build"]) mock.rows[0].payload.files = [ { data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts", mention: { start: 8, end: 16, text: "@main.ts" }, }, ] mock.rows[0].payload.agents = [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }] const view = await openQueue(page, mock) await view.rows.getByRole("button", { name: "Undo" }).click() await expect(view.input).toHaveText("inspect @main.ts with @build") await view.input.press("Enter") await expect.poll(() => mock.prompts.length).toBe(1) expect(mock.prompts[0].files).toMatchObject([ { uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } }, ]) expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }]) }) test("Undo does not discard hidden file context", async ({ page }) => { const mock = createQueueMock(["inspect this file"]) mock.rows[0].payload.files = [ { data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" }, ] const view = await openQueue(page, mock) await view.rows.getByRole("button", { name: "Undo" }).click() await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible() await expect(view.rows).toHaveCount(1) await expect(view.input).toHaveText("") expect(mock.changes).toEqual([]) }) for (const delivery of ["steer", "queue"] as const) { test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({ page, }, testInfo) => { const model = { id: "queue-model", providerID: "opencode" } const userID = "msg_queue_initial_user" const assistantID = "msg_queue_continued_assistant" const followUp = "U2: Also check the retry path." const mock = createQueueMock( [], [ { id: userID, type: "user", text: "U1: Inspect the queue ordering.", time: { created: 1700000000000 } }, { id: "msg_queue_initial_assistant", type: "assistant", agent: "build", model, content: [{ type: "text", text: "A1: I will inspect the current implementation." }], finish: "tool-calls", time: { created: 1700000000001, completed: 1700000000002 }, }, ], ) const view = await openQueue(page, mock, delivery) const transcript = page.locator("[data-timeline-virtual-content]") const thinking = transcript.locator('[data-timeline-row="Thinking"]') await expect(transcript.getByText("A1: I will inspect the current implementation.", { exact: true })).toBeVisible() await expect(thinking).toHaveCount(0) await expect(view.input).toBeEditable() await view.input.fill(followUp) await view.input.press("Enter") await expect.poll(() => mock.rows.map((row) => row.delivery)).toEqual([delivery]) await expect(view.input).toHaveText("") const inboxID = mock.rows[0].id const pending = transcript.locator(`[data-timeline-row="UserMessage"][data-message-id="${inboxID}"]`) if (delivery === "queue") { const queued = view.rows.filter({ hasText: followUp }) await expect(queued).toBeVisible() await expect(pending).toHaveCount(0) await expect(thinking).toHaveCount(0) await queued.hover() await queued.getByRole("button", { name: "Steer", exact: true }).click() await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }]) } await expect(view.rows).toHaveCount(0) await expect(pending).toContainText(followUp) await expect(thinking).toHaveCount(0) // The next assistant step still belongs to U1: U2 has been admitted, not delivered. mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model, started: Date.now(), }) for (const tool of [ { id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } }, { id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } }, ]) { const ref = { sessionID, assistantMessageID: assistantID, id: tool.id } mock.emit("session.tool.input.started", { ...ref, name: tool.name }) mock.emit("session.tool.input.ended", { ...ref, text: JSON.stringify(tool.input) }) mock.emit("session.tool.called", { ...ref, input: tool.input, executed: true }) mock.emit("session.tool.success", { ...ref, content: [{ type: "text", text: "Inspection complete." }], executed: true, }) } mock.emit("session.step.ended", { sessionID, assistantMessageID: assistantID, finish: "tool-calls", cost: 0, tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, }) const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]') await expect(tools).toBeVisible() await expect(tools).toHaveText(/^Used\s*2\s*Read, Grep$/) await expect(tools.locator('[data-component="context-tool-group-trigger"]')).toHaveAttribute( "aria-label", "Used 2 Read, Grep", ) await expect(thinking).toHaveCount(0) await expect(pending).toBeVisible() expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([ { id: inboxID, delivery: "steer" }, ]) await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") }) await expect(tools.or(pending)).toHaveText([/^Used\s*2\s*Read, Grep$/, /U2: Also check the retry path\./]) await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute( "data-message-id", userID, ) await expect .poll(async () => { const boxes = await Promise.all([tools.boundingBox(), pending.boundingBox()]) return boxes.every((box) => box !== null) && boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y }) .toBe(true) mock.rows.splice(0, 1) mock.emit("session.inbox.delivered", { sessionID, inboxID }) await expect(thinking).toHaveCount(0) await expect(pending).toHaveCount(1) await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2) await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute( "data-message-id", userID, ) const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" } mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() }) mock.emit("session.text.started", { ...later, ordinal: 0 }) mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." }) const response = transcript .locator('[data-timeline-row="AssistantPart"]') .filter({ hasText: "A3: Now checking the retry path for U2." }) await expect(response).toHaveAttribute("data-message-id", inboxID) await expect(thinking).toHaveCount(0) await expect(tools.or(pending).or(response)).toHaveText([ /^Used\s*2\s*Read, Grep$/, /U2: Also check the retry path\./, /A3: Now checking the retry path for U2\./, ]) }) }