import { expect, test } from "bun:test" import { createAppFixture } from "./fixture/app" import { directory, json } from "./fixture/tui-client" import { takeDraft } from "../src/component/prompt/draft-stash" import type { PromptInfo } from "../src/prompt/history" import { copyPlugin } from "./fixture/fixture" const hash = "e".repeat(64) const png = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64", ) const pngURI = `data:image/png;base64,${png.toString("base64")}` const session = { id: "ses_media_refs", title: "Media refs", projectID: "proj_test", location: { directory }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 0, updated: 0 }, } const messages = [ { type: "assistant", id: "msg_reply", agent: "build", model: { providerID: "fixture", id: "fixture" }, finish: "stop", time: { created: 2, completed: 3 }, content: [{ type: "text", text: "Looked at it" }], }, { type: "user", id: "msg_shot", text: "What is in [Image 1]", files: [ { uri: `media:${hash}`, mime: "image/png", source: { type: "inline" }, name: "shot.png", mention: { start: 11, end: 20, text: "[Image 1]" }, }, ], time: { created: 1 }, }, ] // What forking from msg_shot opens: its own transcript, with a different prompt to undo. const forked = { ...session, id: "ses_forked", title: "Forked" } const forkedMessages = [ { ...messages[0], id: "msg_forked_reply" }, { ...messages[1], id: "msg_forked_ask", text: "Forked ask [Image 1]" }, ] const queuedShot = { id: "msg_queued", sessionID: session.id, type: "user", delivery: "queue", payload: messages[1], time: { created: 4 }, } // The requests a rewind makes, logged with their status. const MEDIA = `GET /api/experimental/media/${hash}` const REVERT = `POST /api/session/${session.id}/revert/stage` const CANCEL = `DELETE /api/session/${session.id}/inbox/msg_queued` const FORK = `POST /api/session/${session.id}/fork` const FORKED_REVERT = `POST /api/session/${forked.id}/revert/stage` type Respond = () => Response | Promise const image: Respond = () => new Response(png, { headers: { "content-type": "image/png" } }) // What the server answers once a blob is swept, and a failure where the blob may still exist. const gone: Respond = () => json({ _tag: "FileNotFoundError", path: `/api/experimental/media/${hash}`, message: "gone" }, { status: 404 }) const unavailable: Respond = () => new Response("unavailable", { status: 503 }) /** Opens the session against a fake server. `calls` logs media reads and rewinds as ` `. */ async function openSession(server: { inbox?: unknown[]; plugins?: string[]; respond?: Record }) { // Drafts are stashed per tab for the whole process, so an earlier test or test file would prefill a composer. A home // draft would also turn the slash command typed there by `leaveAndReturn` into more text. for (const id of [undefined, session.id, forked.id]) takeDraft(id) const calls: string[] = [] const capabilities = new Map() const logged: Partial> = { [MEDIA]: image, [REVERT]: () => json({ data: session }), [CANCEL]: () => new Response(null, { status: 204 }), [FORK]: () => json({ data: forked }), [FORKED_REVERT]: () => json({ data: forked }), ...server.respond, } const setup = await createAppFixture({ width: 100, height: 30, config: { animations: false, tabs: { enabled: false }, plugins: server.plugins }, args: { sessionID: session.id }, fetch: async (url, request) => { const key = `${request.method} ${url.pathname}` const [, id, rest] = /^\/api\/(?:experimental\/)?session\/([^/]+)(.*)$/.exec(url.pathname) ?? [] const info = [session, forked].find((item) => item.id === id) if (rest === "/message") { capabilities.set(url.pathname, request.headers.get("x-opencode-capabilities")) return json({ data: id === session.id ? messages : id === forked.id ? forkedMessages : [], cursor: {} }) } if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (info && (rest === "" || rest === "/interrupt")) return json({ data: info }) if (info && rest === "/inbox") return json({ data: id === session.id ? (server.inbox ?? []) : [] }) if (info && rest === "/permission") return json({ data: [] }) if (info && rest === "/wait") return new Response(null, { status: 204 }) const respond = logged[key] if (!respond) return undefined // Logged on arrival, so a test can act while a read is still waiting; the status is added once it answers. calls.push(key) const response = await respond() calls[calls.lastIndexOf(key)] = `${key} ${response.status}` return response }, }) await setup.waitForFrame((frame) => frame.includes("Looked at it")) return Object.assign(setup, { calls, capabilities }) } type App = Awaited> async function undoLast(app: App) { await app.mockInput.typeText("/undo") app.mockInput.pressEnter() } async function undoQueued(app: App) { app.mockInput.pressKey("x", { ctrl: true }) app.mockInput.pressKey("q") await app.waitForFrame((frame) => frame.includes("Queued prompts")) app.mockInput.pressKey("u", { ctrl: true }) await app.waitForFrame(() => true) } // Picks one of a message's actions from /timeline. async function messageAction(app: App, action: string) { await app.mockInput.typeText("/timeline") app.mockInput.pressEnter() await app.waitForFrame((frame) => frame.includes("Jump to message") || frame.includes("Timeline")) app.mockInput.pressEnter() await app.waitForFrame((frame) => frame.includes(action)) await app.mockInput.typeText(action) app.mockInput.pressEnter() } // Waits for `done`, then gives anything that shouldn't follow, such as a rewind after a failed read or a composer // change, time to show before reading the frame. async function settled(app: App, done: (frame: string) => boolean) { await app.waitForFrame(done) await Bun.sleep(50) return app.waitForFrame(() => true) } // How many times the frame shows the prompt: once in the transcript, twice once it is also back in the composer. const shown = (frame: string) => frame.split("What is in [Image 1]").length - 1 test("reads transcripts with media refs and loads a ref's bytes before /undo rewinds its message", async () => { await using app = await openSession({}) expect([...app.capabilities.values()]).toEqual(["experimental-media-uri"]) // Image previews are off by default, so showing the transcript reads no media. expect(app.calls).toEqual([]) await undoLast(app) await app.waitForFrame((frame) => shown(frame) === 2) expect(app.calls).toEqual([`${MEDIA} 200`, `${REVERT} 200`]) }, 20000) // Starts an undo, leaves the session while its media read waits, then lets the read finish. async function undoAfterLeaving(input: { inbox: unknown[]; undo: (app: App) => Promise }) { const media = Promise.withResolvers() await using app = await openSession({ inbox: input.inbox, respond: { [MEDIA]: () => media.promise.then(image) } }) await input.undo(app) await app.waitForFrame(() => app.calls.length > 0) await app.mockInput.typeText("/new") app.mockInput.pressEnter() await app.waitForFrame((frame) => !frame.includes("Looked at it")) media.resolve() // The rewind's request is logged on arrival, but the draft is written only once its response is back. let draft: PromptInfo | undefined await app.waitForFrame(() => (draft ??= takeDraft(session.id)?.prompt) !== undefined) return { calls: app.calls, draft } } test("an /undo that finishes after its session closed keeps the prompt in that session's draft", async () => { const { calls, draft } = await undoAfterLeaving({ inbox: [], undo: undoLast }) expect(calls).toEqual([`${MEDIA} 200`, `${REVERT} 200`]) expect(draft?.text).toBe("What is in [Image 1]") expect(draft?.files?.map((file) => file.uri)).toEqual([pngURI]) }, 20000) test("undoing a queued prompt after its session closed cancels it in that session and keeps it as a draft", async () => { const { calls, draft } = await undoAfterLeaving({ inbox: [queuedShot], undo: async (app) => { await undoQueued(app) app.mockInput.pressEscape() }, }) expect(calls).toEqual([`${MEDIA} 200`, `${CANCEL} 204`]) expect(draft?.text).toBe("What is in [Image 1]") expect(draft?.files?.map((file) => file.uri)).toEqual([pngURI]) }, 20000) // Undoes a prompt whose image read fails, until `done` shows the outcome. The composer is read from the draft it leaves // when the app closes. async function undoWithFailedRead(media: Respond, queued: boolean, done: (frame: string, app: App) => boolean) { const result = await (async () => { await using app = await openSession({ inbox: queued ? [queuedShot] : [], respond: { [MEDIA]: media } }) await (queued ? undoQueued(app) : undoLast(app)) const frame = await settled(app, (frame) => app.calls.length > 0 && done(frame, app)) return { calls: app.calls, frame } })() return { ...result, draft: takeDraft(session.id)?.prompt } } for (const queued of [false, true]) { const name = queued ? "undoing a queued prompt" : "/undo" test(`${name} goes ahead without an image the server no longer has`, async () => { // The warning shows before the rewind; the prompt reaches the composer once it's done. const { calls, frame, draft } = await undoWithFailedRead( gone, queued, (frame, app) => frame.includes("no longer stored") && !!app.renderer.currentFocusedEditor?.plainText.includes("What is in"), ) expect(calls).toEqual([`${MEDIA} 404`, queued ? `${CANCEL} 204` : `${REVERT} 200`]) expect(draft?.text).toBe("What is in [Image 1]") expect(draft?.files).toEqual([]) expect(frame).toContain("no longer stored") }, 20000) test(`${name} keeps the message when an image read fails for another reason`, async () => { const { calls, draft } = await undoWithFailedRead(unavailable, queued, (frame) => /unavailable|503/i.test(frame)) expect(calls).toEqual([`${MEDIA} 503`]) expect(draft).toBeUndefined() }, 20000) } test("a revert the server refuses leaves the composer as it was", async () => { await using app = await openSession({ respond: { [REVERT]: () => json({ name: "BusyError", data: {} }, { status: 409 }) }, }) await messageAction(app, "Revert") const frame = await settled(app, () => app.calls.includes(`${REVERT} 409`)) expect(app.calls).toEqual([`${MEDIA} 200`, `${REVERT} 409`]) expect(shown(frame)).toBe(1) }, 20000) test("a revert goes ahead without an image the server no longer has", async () => { await using app = await openSession({ respond: { [MEDIA]: gone } }) await messageAction(app, "Revert") const frame = await settled(app, (frame) => shown(frame) === 2) expect(app.calls).toEqual([`${MEDIA} 404`, `${REVERT} 200`]) expect(shown(frame)).toBe(2) expect(frame).toContain("no longer stored") }, 20000) test("a revert whose image read fails for another reason does not revert", async () => { await using app = await openSession({ respond: { [MEDIA]: unavailable } }) await messageAction(app, "Revert") const frame = await settled(app, () => app.calls.includes(`${MEDIA} 503`)) expect(app.calls).toEqual([`${MEDIA} 503`]) expect(shown(frame)).toBe(1) }, 20000) test("forking from a prompt whose image is gone does not fork", async () => { await using app = await openSession({ respond: { [MEDIA]: gone } }) await messageAction(app, "Fork") await settled(app, () => app.calls.includes(`${MEDIA} 404`)) expect(app.calls).toEqual([`${MEDIA} 404`]) }, 20000) test("plugins read messages with inline bytes while the transcript reads media refs", async () => { await using plugin = await copyPlugin("client-reads") await using app = await openSession({ plugins: [plugin.directory] }) await app.waitForFrame(() => app.capabilities.size === 2) expect(Object.fromEntries(app.capabilities)).toEqual({ [`/api/session/${session.id}/message`]: "experimental-media-uri", "/api/session/ses_plugin_probe/message": null, }) }, 20000) async function leaveAndReturn(app: App) { // A leader key, since text in the composer would turn a slash command into more text. app.mockInput.pressKey("x", { ctrl: true }) app.mockInput.pressKey("n") await app.waitForFrame((frame) => !frame.includes("Looked at it")) await app.mockInput.typeText("/sessions") app.mockInput.pressEnter() await app.waitForFrame((frame) => frame.includes("Sessions") && frame.includes("Media refs")) app.mockInput.pressEnter() await app.waitForFrame((frame) => frame.includes("Looked at it")) } for (const queued of [false, true]) { const name = queued ? "a queued-prompt undo" : "an /undo" test(`${name} that finishes after leaving and returning joins what was typed since`, async () => { const media = Promise.withResolvers() const result = await (async () => { await using app = await openSession({ inbox: queued ? [queuedShot] : [], respond: { [MEDIA]: () => media.promise.then(image) }, }) if (queued) { await undoQueued(app) app.mockInput.pressEscape() } else await undoLast(app) await app.waitForFrame(() => app.calls.length > 0) await leaveAndReturn(app) await app.mockInput.typeText("typed note") media.resolve() // The rewind finishes by appending the prompt to the live composer, after what was typed. await app.waitForFrame(() => !!app.renderer.currentFocusedEditor?.plainText.includes("What is in")) await leaveAndReturn(app) return { calls: app.calls, frame: await app.waitForFrame((frame) => frame.includes("typed note")) } })() const draft = takeDraft(session.id)?.prompt expect(result.calls).toEqual([`${MEDIA} 200`, queued ? `${CANCEL} 204` : `${REVERT} 200`]) expect(result.frame).toContain("typed note") expect(draft?.text).toBe("typed note\n\nWhat is in [Image 1]") expect(draft?.files?.map((file) => file.uri)).toEqual([pngURI]) }, 30000) } test("an undo in a session opened with a seeded prompt, such as a fork, lands in its composer", async () => { const result = await (async () => { await using app = await openSession({}) await messageAction(app, "Fork") // The fork opens with the forked-from prompt already in its composer. await app.waitForFrame((frame) => frame.includes("Forked ask") && frame.includes("What is in [Image 1]")) app.mockInput.pressKey("x", { ctrl: true }) app.mockInput.pressKey("u") // Once in the transcript and once more in the composer, after the seeded prompt. const frame = await settled(app, (frame) => frame.split("Forked ask").length - 1 === 2) return { calls: app.calls, frame } })() const draft = takeDraft(forked.id)?.prompt expect(result.calls).toEqual([`${MEDIA} 200`, `${FORK} 200`, `${FORKED_REVERT} 200`]) expect(result.frame).toContain("What is in [Image 1]") expect(draft?.text).toBe("What is in [Image 1]\n\nForked ask [Image 1]") expect(draft?.files?.map((file) => file.uri)).toEqual([pngURI, pngURI]) }, 30000)