diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 5de7d94ca4..30af5d8a75 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -53,6 +53,7 @@ import { useLocation } from "../../context/location" import { Keymap, type KeymapCommand } from "../../context/keymap" import { abbreviateHome } from "../../runtime" import { PluginSlot } from "../../plugin/render" +import { createPromptSubmission } from "../../prompt/submission" export type PromptProps = { sessionID?: string @@ -252,6 +253,7 @@ export function Prompt(props: PromptProps) { const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const connected = useConnected() + const promptSubmission = createPromptSubmission() const hasRightContent = createMemo(() => Boolean(props.right)) function promptModelWarning() { @@ -952,27 +954,72 @@ export function Prompt(props: PromptProps) { } const variant = local.model.variant.current() - let sessionID = props.sessionID + const currentMode = store.mode + const prompt = { + text: store.prompt.text, + files: store.prompt.files?.map((file) => ({ + ...file, + mention: file.mention && { ...file.mention }, + })), + agents: store.prompt.agents?.map((agent) => ({ + ...agent, + mention: agent.mention && { ...agent.mention }, + })), + pasted: store.prompt.pasted.map((part) => ({ + ...part, + source: { ...part.source }, + })), + } satisfies PromptInfo + const inputText = expandTrackedPastedText( + prompt.text, + input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { + const ref = store.extmarkToPart.get(extmark.id) + if (ref?.type !== "pasted") return [] + const part = prompt.pasted[ref.index] + if (!part) return [] + return [{ start: extmark.start, end: extmark.end, text: part.text }] + }), + ) + const directory = props.sessionID == null ? await move.getDirectory() : undefined + if (props.sessionID == null && move.pending() && !directory) return false + const sessionInput = { + location: directory ? { directory } : (currentLocation.ref ?? data.location.default()), + agent: agent.id, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + } + const promptInput = { + text: inputText, + files: prompt.files, + agents: prompt.agents, + } + // Keep both IDs stable until the whole create/admit sequence succeeds. If the + // transport drops after either durable write, retry reconciles that write. + const sessionID = await promptSubmission.begin( + Bun.hash( + JSON.stringify({ + sessionID: props.sessionID, + session: sessionInput, + prompt: promptInput, + mode: currentMode, + }), + ), + props.sessionID, + ) let session = sessionID ? data.session.get(sessionID) : undefined let finishMoveProgress = false - if (sessionID == null) { - const directory = await move.getDirectory() - if (move.pending() && !directory) return false + if (props.sessionID == null) { finishMoveProgress = Boolean(move.progress()) // The location context is where the next session is created: seeded by the home // route (launch cwd, inherited session location, or picked project) and updated // by /cd before a session exists. - const location = currentLocation.ref ?? data.location.default() - const created = await client.api.session .create({ - location: directory ? { directory } : location, - agent: agent.id, - model: { - providerID: selectedModel.providerID, - id: selectedModel.modelID, - variant, - }, + id: sessionID, + ...sessionInput, }) .catch(() => undefined) @@ -986,27 +1033,13 @@ export function Prompt(props: PromptProps) { return true } - sessionID = created.id session = created } - const inputText = expandTrackedPastedText( - store.prompt.text, - input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { - const ref = store.extmarkToPart.get(extmark.id) - if (ref?.type !== "pasted") return [] - const part = store.prompt.pasted[ref.index] - if (!part) return [] - return [{ start: extmark.start, end: extmark.end, text: part.text }] - }), - ) - - // Capture mode before it gets reset - const currentMode = store.mode const editorSelection = editorContext() const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined - if (store.mode === "shell") { + if (currentMode === "shell") { move.startSubmit() void client.api.session.shell({ sessionID, @@ -1033,9 +1066,9 @@ export function Prompt(props: PromptProps) { command: command.slice(1), arguments: args, agent: agent.id, - model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, - files: store.prompt.files, - agents: store.prompt.agents, + model: sessionInput.model, + files: promptInput.files, + agents: promptInput.agents, }) .catch((error) => { toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) @@ -1067,7 +1100,7 @@ export function Prompt(props: PromptProps) { ) { await client.api.session.switchModel({ sessionID, - model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, + model: sessionInput.model, }) } if (session?.revert) { @@ -1100,9 +1133,8 @@ export function Prompt(props: PromptProps) { const error = await client.api.session .prompt({ sessionID, - text: inputText, - files: store.prompt.files, - agents: store.prompt.agents, + id: await promptSubmission.message(), + ...promptInput, }) .then( () => undefined, @@ -1114,8 +1146,9 @@ export function Prompt(props: PromptProps) { } if (pendingEditorSelection) editor.markSelectionSent() } + promptSubmission.complete() history.append({ - ...store.prompt, + ...prompt, mode: currentMode, }) input.extmarks.clear() diff --git a/packages/tui/src/prompt/submission.ts b/packages/tui/src/prompt/submission.ts new file mode 100644 index 0000000000..f3c3155d4e --- /dev/null +++ b/packages/tui/src/prompt/submission.ts @@ -0,0 +1,35 @@ +type PromptSubmission = { + key: number | bigint + sessionID: string + messageID?: string +} + +export function createPromptSubmission() { + let pending: PromptSubmission | undefined + + return { + async begin(key: number | bigint, sessionID?: string) { + if (pending?.key === key && (sessionID === undefined || pending.sessionID === sessionID)) return pending.sessionID + if (sessionID !== undefined) { + pending = { key, sessionID } + return pending.sessionID + } + const { SessionID } = await import("@opencode-ai/schema/session-id") + pending = { + key, + sessionID: SessionID.create(), + } + return pending.sessionID + }, + async message() { + if (!pending) throw new Error("Prompt submission has not started") + if (pending.messageID) return pending.messageID + const { SessionMessage } = await import("@opencode-ai/schema/session-message") + pending.messageID = SessionMessage.ID.create() + return pending.messageID + }, + complete() { + pending = undefined + }, + } +} diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index da73a047a3..3a624a7a15 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -302,3 +302,123 @@ test("session startup prompt is submitted exactly once", async () => { mock.restore() } }) + +test("home prompt retry reuses the accepted session and message IDs", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const ready = Promise.withResolvers() + const sessionReady = Promise.withResolvers() + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + setup.renderer.setTerminalTitle = (title) => { + if (title === "OpenCode") ready.resolve() + if (title === "OC | New session") sessionReady.resolve() + setTitle(title) + } + const events = createEventStream() + const cwd = process.cwd() + const location = { directory: cwd, project: { id: "project", directory: cwd } } + const creates: unknown[] = [] + const prompts: unknown[] = [] + const modelLoaded = Promise.withResolvers() + const firstPrompt = Promise.withResolvers() + const secondPrompt = Promise.withResolvers() + let createdID: string | undefined + const session = (id: string) => ({ + id, + title: "New session", + projectID: "project", + location: { directory: cwd }, + agent: "build", + model: { providerID: "provider", id: "model" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }) + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/agent") + return json({ + location, + data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }], + }) + if (url.pathname === "/api/model") { + modelLoaded.resolve() + return json({ + location, + data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }], + }) + } + if (url.pathname === "/api/session" && request.method === "GET") + return json({ data: createdID ? [session(createdID)] : [], cursor: {} }) + if (url.pathname === "/api/session" && request.method === "POST") { + const body = await request.json() + if (!body || typeof body !== "object" || !("id" in body) || typeof body.id !== "string") { + throw new Error("session create did not supply an ID") + } + creates.push(body) + createdID = body.id + return json({ data: session(body.id) }) + } + if (createdID && url.pathname === `/api/session/${createdID}`) return json({ data: session(createdID) }) + if (createdID && url.pathname === `/api/session/${createdID}/message`) return json({ data: [], cursor: {} }) + if (createdID && url.pathname === `/api/session/${createdID}/pending`) return json({ data: [] }) + if (createdID && url.pathname === `/api/session/${createdID}/permission`) return json({ data: [] }) + if (createdID && url.pathname === `/api/session/${createdID}/prompt`) { + prompts.push(await request.json()) + if (prompts.length === 1) { + firstPrompt.resolve() + return json({ error: "response lost after admission" }, { status: 500 }) + } + secondPrompt.resolve() + return json({ data: {} }) + } + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: {}, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await Promise.all([ready.promise, modelLoaded.promise]) + await setup.mockInput.typeText("RETRY_READY") + setup.mockInput.pressEnter() + await Promise.race([ + firstPrompt.promise, + Bun.sleep(2_000).then(() => { + throw new Error("first home prompt was not submitted") + }), + ]) + await setup.waitForFrame((frame) => frame.includes("Failed to send prompt")) + setup.mockInput.pressEnter() + await Promise.race([ + secondPrompt.promise, + Bun.sleep(2_000).then(() => { + throw new Error("home prompt was not retried") + }), + ]) + await sessionReady.promise + setup.renderer.destroy() + await task + + expect(creates).toHaveLength(2) + expect(prompts).toHaveLength(2) + expect(creates[1]).toEqual(creates[0]) + expect(prompts[1]).toEqual(prompts[0]) + expect(creates[0]).toMatchObject({ id: expect.stringMatching(/^ses_/) }) + expect(prompts[0]).toMatchObject({ id: expect.stringMatching(/^msg_/), text: "RETRY_READY" }) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + mock.restore() + } +}) diff --git a/packages/tui/test/prompt/submission.test.ts b/packages/tui/test/prompt/submission.test.ts new file mode 100644 index 0000000000..f2c3ff8a1d --- /dev/null +++ b/packages/tui/test/prompt/submission.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { Session } from "@opencode-ai/schema/session" +import { createPromptSubmission } from "../../src/prompt/submission" + +describe("prompt submission identity", () => { + test("reuses identities while retrying the same submission", async () => { + const submission = createPromptSubmission() + const firstSession = await submission.begin(1n) + const firstMessage = await submission.message() + + expect(await submission.begin(1n)).toBe(firstSession) + expect(await submission.message()).toBe(firstMessage) + expect(await submission.begin(2n)).not.toBe(firstSession) + expect(await submission.message()).not.toBe(firstMessage) + }) + + test("preserves an existing session while retrying its prompt", async () => { + const submission = createPromptSubmission() + const sessionID = Session.ID.create() + + expect(await submission.begin(1n, sessionID)).toBe(sessionID) + expect(await submission.begin(1n, sessionID)).toBe(sessionID) + }) + + test("starts a new identity after completion", async () => { + const submission = createPromptSubmission() + const first = await submission.begin(1n) + submission.complete() + + expect(await submission.begin(1n)).not.toBe(first) + }) +})