From 9fd39fc092c10e00f36770eafda427e70ce355f5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 17:18:59 -0400 Subject: [PATCH 1/5] feat(client): optimistic prompt admission with client-minted IDs The data layer's new session.prompt renders the prompt immediately under a client-minted inbox ID and lets the durable inbox.enqueued echo reconcile by that same ID. A rejection rolls back only unacknowledged rows, a racing pending re-fetch preserves in-flight admissions, and retries reuse the same ID so the server's idempotent admission cannot double-admit. TUI and web submit through it. --- .../components/prompt-input/submit.test.ts | 3 + .../app/src/components/prompt-input/submit.ts | 4 +- packages/client/src/solid/data.ts | 133 +++++++++---- packages/tui/src/component/prompt/index.tsx | 4 +- packages/tui/test/cli/tui/data.test.tsx | 186 ++++++++++++++++++ 5 files changed, 289 insertions(+), 41 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 5397816afcba..1168051e0a92 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -235,6 +235,9 @@ beforeAll(async () => { session: { remember: () => undefined, setStatus: () => undefined, + // Mirrors the data layer's optimistic prompt admission by delegating + // to the same API client the submit flow targets. + prompt: (input: unknown) => rootClient.api.session.prompt(input as never), }, location: { info: () => ({ project: { id: "project", directory: "/repo/main" } }), diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index ecb9c2bfc82f..e5a1541ea020 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -132,7 +132,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) { }) } - await input.api.prompt({ + // The data layer admits optimistically: the prompt renders immediately + // and rolls back if the server rejects it. + await input.data.session.prompt({ sessionID: input.draft.sessionID, id: messageID, text: request.text, diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 32eb7546eb1d..c82d9d8a9490 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -34,7 +34,9 @@ import type { WebSearchProvider, } from "../promise" import { Worktree } from "@opencode-ai/schema/worktree" +import { SessionMessage } from "@opencode-ai/schema/session-message" import { isPermissionNotFoundError } from "../promise" +import type { SessionPromptInput } from "../promise" import { createStore, produce, reconcile } from "solid-js/store" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" import { createEffect, createSignal, onCleanup } from "solid-js" @@ -219,6 +221,47 @@ export function createData(config: CreateDataInput) { setStore("session", "pending", sessionID, index, { ...item, delivery }) } + // Inbox IDs of optimistic prompt admissions still awaiting their durable + // echo. This is the one deliberate piece of in-flight bookkeeping in this + // layer: it exists so a rejection only rolls back rows the server never + // acknowledged, and so a concurrent pending re-fetch cannot wipe a row the + // server does not know about yet. + const outbox = new Set() + + // Insert an admitted inbox item into pending, input, and (for user and + // synthetic items) the visible transcript. Used by the inbox.enqueued + // handler and by optimistic prompt admission; both paths dedupe by inbox ID, + // so whichever runs second is a no-op. + function admitLocal(item: SessionInboxInfo) { + addPending(item) + if (!store.session.input[item.sessionID]?.includes(item.id)) + setStore("session", "input", item.sessionID, [...(store.session.input[item.sessionID] ?? []), item.id]) + if (item.type !== "user" && item.type !== "synthetic") return + message.update(item.sessionID, (draft, index) => { + message.append( + draft, + index, + item.type === "user" + ? { id: item.id, type: "user", ...item.payload, time: { created: item.timeCreated } } + : { id: item.id, type: "synthetic", ...item.payload, time: { created: item.timeCreated } }, + ) + }) + } + + // Remove an inbox item from pending, input, and the visible transcript. + // Used by the inbox.cancelled handler and by optimistic rollback. + function retractLocal(sessionID: string, inboxID: string) { + removePending(sessionID, inboxID) + if (!messageIndex.get(sessionID)?.has(inboxID)) return + message.update(sessionID, (draft, index) => { + const position = index.get(inboxID) + if (position === undefined) return + draft.splice(position, 1) + index.delete(inboxID) + message.reindex(draft, index, position) + }) + } + const message = { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( @@ -493,49 +536,16 @@ export function createData(config: CreateDataInput) { updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery) return case "session.inbox.cancelled": { - removePending(event.data.sessionID, event.data.inboxID) - if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID)) - message.update(event.data.sessionID, (draft, index) => { - const position = index.get(event.data.inboxID) - if (position === undefined) return - draft.splice(position, 1) - index.delete(event.data.inboxID) - message.reindex(draft, index, position) - }) + retractLocal(event.data.sessionID, event.data.inboxID) return } case "session.inbox.enqueued": { - const item = event.data.item - addPending({ + outbox.delete(event.data.inboxID) + admitLocal({ id: event.data.inboxID, sessionID: event.data.sessionID, timeCreated: event.created, - ...item, - }) - if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID)) - setStore("session", "input", event.data.sessionID, [ - ...(store.session.input[event.data.sessionID] ?? []), - event.data.inboxID, - ]) - if (item.type !== "user" && item.type !== "synthetic") return - message.update(event.data.sessionID, (draft, index) => { - message.append( - draft, - index, - item.type === "user" - ? { - id: event.data.inboxID, - type: "user", - ...item.payload, - time: { created: event.created }, - } - : { - id: event.data.inboxID, - type: "synthetic", - ...item.payload, - time: { created: event.created }, - }, - ) + ...event.data.item, }) return } @@ -1062,12 +1072,19 @@ export function createData(config: CreateDataInput) { sync(sessionID: string) { return sync.run(`session.pending:${sessionID}`, async () => { const pending = await api().session.inbox.list({ sessionID }) - setStore("session", "pending", sessionID, reconcile(pending)) + // Keep optimistic rows still awaiting their echo: this fetch may + // have raced ahead of an in-flight admission the server does not + // know about yet. + const inflight = (store.session.pending[sessionID] ?? []).filter( + (item) => outbox.has(item.id) && !pending.some((row) => row.id === item.id), + ) + const merged = [...pending, ...inflight] + setStore("session", "pending", sessionID, reconcile(merged)) setStore( "session", "input", sessionID, - reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)), + reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)), ) }) }, @@ -1075,6 +1092,44 @@ export function createData(config: CreateDataInput) { sync.invalidate(`session.pending:${sessionID}`) }, }, + // Optimistic prompt admission: render the prompt immediately under a + // client-minted ID, send it, and let the durable inbox.enqueued echo + // reconcile by that same ID. Server admission is idempotent per ID, so + // retrying with the identical payload cannot double-admit. + async prompt(input: SessionPromptInput) { + const id = input.id ?? SessionMessage.ID.create() + outbox.add(id) + admitLocal({ + id, + sessionID: input.sessionID, + timeCreated: Date.now(), + type: "user", + delivery: input.delivery ?? "steer", + // Files and skills stay off the optimistic row: their durable forms + // are server-loaded (content, mime, resolution), so they render + // fully when the echo arrives. + payload: { + text: input.text, + agents: input.agents?.map((agent) => ({ ...agent })), + metadata: input.metadata, + }, + }) + return api() + .session.prompt({ ...input, id }) + .then( + (admitted) => { + outbox.delete(id) + return admitted + }, + (error) => { + // Roll back only while unacknowledged: once the echo confirmed + // the row it is server state, and a late transport failure must + // not delete it. + if (outbox.delete(id)) retractLocal(input.sessionID, id) + throw error + }, + ) + }, sync(sessionID: string, options?: { children?: boolean }) { return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { const [info, children] = await Promise.all([ diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index e3e37358a8d6..901f0e705df1 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1304,7 +1304,9 @@ export function Prompt(props: PromptProps) { return false } } - const error = await client.api.session + // The data layer admits optimistically: the prompt renders immediately + // and rolls back if the server rejects it. + const error = await data.session .prompt({ sessionID, text: inputText, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 4f7a3b852119..4f2cb50d96b2 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -3094,3 +3094,189 @@ test("stops at the last non-repeating ancestor on a parent cycle", async () => { app.renderer.destroy() } }) + +test("admits prompts optimistically and reconciles with the durable echo", async () => { + const events = createEventStream() + const sessionID = "session-1" + let release!: (response: Response) => void + const deferred = new Promise((resolve) => { + release = resolve + }) + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/prompt`) return deferred + // The server does not know about the in-flight admission yet. + if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] }) + }, events) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + const promise = sync.session.prompt({ sessionID, text: "hello" }) + const settled = promise.then( + () => undefined, + (error) => error, + ) + + // Optimistic: the row renders before the server responds. + const optimistic = sync.session.pending.list(sessionID)[0] + expect(optimistic).toMatchObject({ sessionID, type: "user", payload: { text: "hello" }, delivery: "steer" }) + const messageID = optimistic!.id + expect(messageID.startsWith("msg_")).toBe(true) + expect(sync.session.input.list(sessionID)).toEqual([messageID]) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + + // A pending re-fetch racing the in-flight admission cannot wipe the row. + await sync.session.pending.sync(sessionID) + expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) + expect(sync.session.input.list(sessionID)).toEqual([messageID]) + + // The durable echo reconciles by ID instead of duplicating. + const received: string[] = [] + const unsubscribe = sync.listen((event) => received.push(event.name)) + emitEvent(events, { + id: "evt_echo_1", + created: 5, + type: "session.inbox.enqueued", + durable: durable(sessionID), + data: { sessionID, inboxID: messageID, item: { type: "user", payload: { text: "hello" }, delivery: "steer" } }, + }) + await wait(() => received.includes("session.inbox.enqueued")) + unsubscribe() + expect(sync.session.pending.list(sessionID)).toHaveLength(1) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + + // A late transport failure after the echo must not delete acknowledged state. + release(json({ _tag: "UnknownError", message: "response lost" }, { status: 500 })) + expect(await settled).toBeDefined() + expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + } finally { + app.renderer.destroy() + } +}) + +test("rolls back an optimistic prompt the server rejected", async () => { + const events = createEventStream() + const sessionID = "session-1" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/prompt`) + return json({ _tag: "InvalidRequestError", message: "invalid" }, { status: 400 }) + }, events) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + const promise = sync.session.prompt({ sessionID, text: "rejected" }) + expect(sync.session.message.list(sessionID)).toHaveLength(1) + + await expect(promise).rejects.toThrow() + expect(sync.session.pending.list(sessionID)).toEqual([]) + expect(sync.session.input.list(sessionID)).toEqual([]) + expect(sync.session.message.list(sessionID)).toEqual([]) + } finally { + app.renderer.destroy() + } +}) + +test("a retry under the same client-minted ID cannot duplicate rows", async () => { + const events = createEventStream() + const sessionID = "session-1" + const messageID = "msg_retry_1" + const admission = { + id: messageID, + sessionID, + timeCreated: 1, + type: "user", + payload: { text: "hello" }, + delivery: "steer", + } + const posts: string[] = [] + const calls = createFetch(async (url, request) => { + if (url.pathname === `/api/session/${sessionID}/prompt`) { + posts.push(((await request.json()) as { id: string }).id) + return json({ data: admission }) + } + }, events) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) + // Retry with the identical payload: server admission is idempotent per ID, + // and the local dedupe keeps a single row. + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) + + expect(posts).toEqual([messageID, messageID]) + expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) + expect(sync.session.input.list(sessionID)).toEqual([messageID]) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + } finally { + app.renderer.destroy() + } +}) From 6ff6a07f92a729f8093f22df58de7a7222533c4b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 17:39:56 -0400 Subject: [PATCH 2/5] refactor(client): harden optimistic prompt admission Review-pass fixes from the simplify skill's three-agent review: - admitLocal upserts instead of deduping, so the durable enqueued echo replaces the optimistic placeholder (server-loaded files and durable timeCreated land instead of being silently ignored) - outbox entries clear on the echo or rollback, not on POST success, closing a window where a racing pending re-fetch could wipe the row between response and echo - prompt() admits optimistically only for fresh IDs, so a failed retry under an already-acknowledged ID cannot roll back server state - rollback is reachable even when the client throws synchronously - removeSession sweeps outbox entries for the deleted session - admitLocal/retractLocal batch their store writes - tests: echo upsert asserted with durable-only data (files, time), new POST-before-echo race test, failed-retry-after-ack case --- .../components/prompt-input/submit.test.ts | 4 +- packages/client/src/solid/data.ts | 141 ++++++++++-------- packages/tui/test/cli/tui/data.test.tsx | 106 ++++++++++++- 3 files changed, 180 insertions(+), 71 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 1168051e0a92..b09880d2b0b8 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -235,8 +235,8 @@ beforeAll(async () => { session: { remember: () => undefined, setStatus: () => undefined, - // Mirrors the data layer's optimistic prompt admission by delegating - // to the same API client the submit flow targets. + // Delegates straight to the API client; optimistic admission and + // rollback are covered by the data-layer tests in packages/tui. prompt: (input: unknown) => rootClient.api.session.prompt(input as never), }, location: { diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index c82d9d8a9490..dd3a8d1dca49 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -35,11 +35,10 @@ import type { } from "../promise" import { Worktree } from "@opencode-ai/schema/worktree" import { SessionMessage } from "@opencode-ai/schema/session-message" -import { isPermissionNotFoundError } from "../promise" -import type { SessionPromptInput } from "../promise" +import { isPermissionNotFoundError, type SessionPromptInput } from "../promise" import { createStore, produce, reconcile } from "solid-js/store" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" -import { createEffect, createSignal, onCleanup } from "solid-js" +import { batch, createEffect, createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" @@ -180,11 +179,6 @@ export function createData(config: CreateDataInput) { setStore("session", "active", sessionID, status) } - function addPending(item: SessionInboxInfo) { - if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return - setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item]) - } - function removePending(sessionID: string, inboxID?: string) { if (!inboxID) return if (store.session.pending[sessionID]?.some((item) => item.id === inboxID)) @@ -225,40 +219,53 @@ export function createData(config: CreateDataInput) { // echo. This is the one deliberate piece of in-flight bookkeeping in this // layer: it exists so a rejection only rolls back rows the server never // acknowledged, and so a concurrent pending re-fetch cannot wipe a row the - // server does not know about yet. + // server does not know about yet. Entries clear on the enqueued echo or on + // rollback — not on POST success, which typically precedes the echo. const outbox = new Set() - // Insert an admitted inbox item into pending, input, and (for user and + // Upsert an admitted inbox item into pending, input, and (for user and // synthetic items) the visible transcript. Used by the inbox.enqueued - // handler and by optimistic prompt admission; both paths dedupe by inbox ID, - // so whichever runs second is a no-op. + // handler and by optimistic prompt admission; the upsert is what reconciles + // the durable echo with an optimistic placeholder — the durable payload and + // times replace the client's guess. function admitLocal(item: SessionInboxInfo) { - addPending(item) - if (!store.session.input[item.sessionID]?.includes(item.id)) - setStore("session", "input", item.sessionID, [...(store.session.input[item.sessionID] ?? []), item.id]) - if (item.type !== "user" && item.type !== "synthetic") return - message.update(item.sessionID, (draft, index) => { - message.append( - draft, - index, - item.type === "user" - ? { id: item.id, type: "user", ...item.payload, time: { created: item.timeCreated } } - : { id: item.id, type: "synthetic", ...item.payload, time: { created: item.timeCreated } }, + batch(() => { + const pending = store.session.pending[item.sessionID] ?? [] + const at = pending.findIndex((entry) => entry.id === item.id) + setStore( + "session", + "pending", + item.sessionID, + at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)), ) + const input = store.session.input[item.sessionID] ?? [] + if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id]) + if (item.type !== "user" && item.type !== "synthetic") return + message.update(item.sessionID, (draft, index) => { + const row = + item.type === "user" + ? { id: item.id, type: "user" as const, ...item.payload, time: { created: item.timeCreated } } + : { id: item.id, type: "synthetic" as const, ...item.payload, time: { created: item.timeCreated } } + const position = index.get(item.id) + if (position === undefined) return message.append(draft, index, row) + draft[position] = row + }) }) } // Remove an inbox item from pending, input, and the visible transcript. // Used by the inbox.cancelled handler and by optimistic rollback. function retractLocal(sessionID: string, inboxID: string) { - removePending(sessionID, inboxID) - if (!messageIndex.get(sessionID)?.has(inboxID)) return - message.update(sessionID, (draft, index) => { - const position = index.get(inboxID) - if (position === undefined) return - draft.splice(position, 1) - index.delete(inboxID) - message.reindex(draft, index, position) + batch(() => { + removePending(sessionID, inboxID) + if (!messageIndex.get(sessionID)?.has(inboxID)) return + message.update(sessionID, (draft, index) => { + const position = index.get(inboxID) + if (position === undefined) return + draft.splice(position, 1) + index.delete(inboxID) + message.reindex(draft, index, position) + }) }) } @@ -368,6 +375,7 @@ export function createData(config: CreateDataInput) { } function removeSession(sessionID: string) { + store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id)) messageIndex.delete(sessionID) sync.invalidate(`session:${sessionID}`) sync.invalidate(`session.pending:${sessionID}`) @@ -1078,7 +1086,7 @@ export function createData(config: CreateDataInput) { const inflight = (store.session.pending[sessionID] ?? []).filter( (item) => outbox.has(item.id) && !pending.some((row) => row.id === item.id), ) - const merged = [...pending, ...inflight] + const merged = inflight.length === 0 ? pending : [...pending, ...inflight] setStore("session", "pending", sessionID, reconcile(merged)) setStore( "session", @@ -1094,41 +1102,44 @@ export function createData(config: CreateDataInput) { }, // Optimistic prompt admission: render the prompt immediately under a // client-minted ID, send it, and let the durable inbox.enqueued echo - // reconcile by that same ID. Server admission is idempotent per ID, so - // retrying with the identical payload cannot double-admit. - async prompt(input: SessionPromptInput) { + // upsert that same ID with the server's payload. Server admission is + // idempotent per ID, so retrying with the identical payload cannot + // double-admit. + prompt(input: SessionPromptInput) { const id = input.id ?? SessionMessage.ID.create() - outbox.add(id) - admitLocal({ - id, - sessionID: input.sessionID, - timeCreated: Date.now(), - type: "user", - delivery: input.delivery ?? "steer", - // Files and skills stay off the optimistic row: their durable forms - // are server-loaded (content, mime, resolution), so they render - // fully when the echo arrives. - payload: { - text: input.text, - agents: input.agents?.map((agent) => ({ ...agent })), - metadata: input.metadata, - }, - }) - return api() - .session.prompt({ ...input, id }) - .then( - (admitted) => { - outbox.delete(id) - return admitted - }, - (error) => { - // Roll back only while unacknowledged: once the echo confirmed - // the row it is server state, and a late transport failure must - // not delete it. - if (outbox.delete(id)) retractLocal(input.sessionID, id) - throw error + // A retry may reuse an ID that is already rendered — and possibly + // already durable. Admit optimistically only for new IDs so a failed + // retry cannot roll back acknowledged state. + const fresh = + !messageIndex.get(input.sessionID)?.has(id) && + !store.session.pending[input.sessionID]?.some((item) => item.id === id) + if (fresh) { + outbox.add(id) + admitLocal({ + id, + sessionID: input.sessionID, + timeCreated: Date.now(), + type: "user", + delivery: input.delivery ?? "steer", + // Files and skills stay off the optimistic row: their durable + // forms are server-loaded (content, mime, resolution), so they + // fill in when the echo upserts the row. + payload: { + text: input.text, + agents: input.agents?.map((agent) => ({ ...agent })), + metadata: input.metadata, }, - ) + }) + } + // Wrapped so even a synchronous client failure reaches the rollback. + return Promise.resolve() + .then(() => api().session.prompt({ ...input, id })) + .catch((error) => { + // Roll back only rows this call admitted and the echo has not + // acknowledged: anything else is server state. + if (fresh && outbox.delete(id)) retractLocal(input.sessionID, id) + throw error + }) }, sync(sessionID: string, options?: { children?: boolean }) { return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 4f2cb50d96b2..6531f61f58c4 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -3152,26 +3152,104 @@ test("admits prompts optimistically and reconciles with the durable echo", async expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) expect(sync.session.input.list(sessionID)).toEqual([messageID]) - // The durable echo reconciles by ID instead of duplicating. + // The durable echo upserts by ID instead of duplicating: server-loaded + // payload (files) and durable times replace the optimistic placeholder. const received: string[] = [] const unsubscribe = sync.listen((event) => received.push(event.name)) + const echoFile = { data: "aGVsbG8=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///a.txt" } } emitEvent(events, { id: "evt_echo_1", created: 5, type: "session.inbox.enqueued", durable: durable(sessionID), - data: { sessionID, inboxID: messageID, item: { type: "user", payload: { text: "hello" }, delivery: "steer" } }, + data: { + sessionID, + inboxID: messageID, + item: { type: "user", payload: { text: "hello", files: [echoFile] }, delivery: "steer" }, + }, }) await wait(() => received.includes("session.inbox.enqueued")) unsubscribe() - expect(sync.session.pending.list(sessionID)).toHaveLength(1) - expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + expect(sync.session.pending.list(sessionID)).toEqual([ + { + id: messageID, + sessionID, + timeCreated: 5, + type: "user", + payload: { text: "hello", files: [echoFile] }, + delivery: "steer", + }, + ]) + const echoed = sync.session.message.list(sessionID)[0] + expect(echoed?.type).toBe("user") + if (echoed?.type !== "user") return + expect(echoed.time.created).toBe(5) + expect(echoed.files).toEqual([echoFile]) // A late transport failure after the echo must not delete acknowledged state. release(json({ _tag: "UnknownError", message: "response lost" }, { status: 500 })) expect(await settled).toBeDefined() expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + } finally { + release(json({ _tag: "UnknownError", message: "cleanup" }, { status: 500 })) + app.renderer.destroy() + } +}) + +test("keeps the row when the response lands before the echo", async () => { + const events = createEventStream() + const sessionID = "session-1" + const messageID = "msg_early_1" + const admission = { + id: messageID, + sessionID, + timeCreated: 1, + type: "user", + payload: { text: "hello" }, + delivery: "steer", + } + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/prompt`) return json({ data: admission }) + // The server's inbox listing still misses the admission (projection lag). + if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] }) + }, events) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) + + // POST resolved but the echo has not arrived: a racing pending re-fetch + // still cannot wipe the row. + await sync.session.pending.sync(sessionID) + sync.session.pending.invalidate(sessionID) + await sync.session.pending.sync(sessionID) + expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) + expect(sync.session.input.list(sessionID)).toEqual([messageID]) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) } finally { app.renderer.destroy() } @@ -3235,9 +3313,11 @@ test("a retry under the same client-minted ID cannot duplicate rows", async () = delivery: "steer", } const posts: string[] = [] + let fail = false const calls = createFetch(async (url, request) => { if (url.pathname === `/api/session/${sessionID}/prompt`) { posts.push(((await request.json()) as { id: string }).id) + if (fail) return json({ _tag: "UnknownError", message: "transient" }, { status: 500 }) return json({ data: admission }) } }, events) @@ -3276,6 +3356,24 @@ test("a retry under the same client-minted ID cannot duplicate rows", async () = expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) expect(sync.session.input.list(sessionID)).toEqual([messageID]) expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + + // The row is acknowledged (echo applied): a FAILED retry under the same + // ID must not roll back acknowledged state. + const received: string[] = [] + const unsubscribe = sync.listen((event) => received.push(event.name)) + emitEvent(events, { + id: "evt_ack_1", + created: 2, + type: "session.inbox.enqueued", + durable: durable(sessionID), + data: { sessionID, inboxID: messageID, item: { type: "user", payload: { text: "hello" }, delivery: "steer" } }, + }) + await wait(() => received.includes("session.inbox.enqueued")) + unsubscribe() + fail = true + await expect(sync.session.prompt({ sessionID, id: messageID, text: "hello" })).rejects.toThrow() + expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) } finally { app.renderer.destroy() } From 44629d356f53b37bd674f2f3ab5689921cad855c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 18:15:57 -0400 Subject: [PATCH 3/5] feat(tui): fire-and-forget prompt submission Live end-to-end testing against a real server behind a flaky proxy exposed two gaps the unit tests could not see: - the submit flow awaited the prompt POST before clearing the composer, so under network latency the message displayed twice (transcript + composer) for the whole round-trip, and a brand-new session showed nothing at all because navigation was gated on the same await - message.sync reconciled the server list wholesale, so a re-fetch racing an optimistic admission (guaranteed by immediate navigation) wiped the in-flight transcript row until the echo restored it Submission now fires the prompt without awaiting: the composer clears and the session view opens immediately (replacing the 50ms navigation hack). On rejection the data layer rolls the row back, a toast surfaces the error, and the failed text is restored to the composer unless the user already started typing. message.sync gains the same in-flight merge as pending.sync, with test coverage. --- packages/client/src/solid/data.ts | 9 ++++- packages/tui/src/component/prompt/index.tsx | 37 +++++++++++---------- packages/tui/test/cli/tui/data.test.tsx | 10 ++++-- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index dd3a8d1dca49..3760f5137fd8 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1180,7 +1180,14 @@ export function createData(config: CreateDataInput) { sync(sessionID: string) { return sync.run(`session.message:${sessionID}`, async () => { const response = await api().message.list({ sessionID, limit: 200, order: "desc" }) - const messages = response.data.toReversed() + const fetched = response.data.toReversed() + // Same protection as the pending sync: a re-fetch racing an + // optimistic admission must not wipe the in-flight transcript row. + const ids = new Set(fetched.map((item) => item.id)) + const inflight = (store.session.message[sessionID] ?? []).filter( + (item) => outbox.has(item.id) && !ids.has(item.id), + ) + const messages = inflight.length === 0 ? fetched : [...fetched, ...inflight] messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) setStore("session", "message", sessionID, reconcile(messages)) setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 901f0e705df1..461cb39fc8c4 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1305,8 +1305,11 @@ export function Prompt(props: PromptProps) { } } // The data layer admits optimistically: the prompt renders immediately - // and rolls back if the server rejects it. - const error = await data.session + // and rolls back if the server rejects it, so submission does not wait + // on the network. On rejection the row is already rolled back; restore + // the composer unless the user has started typing something new. + const entry = { ...store.prompt, mode: currentMode } + data.session .prompt({ sessionID, text: inputText, @@ -1315,14 +1318,15 @@ export function Prompt(props: PromptProps) { skills: store.prompt.skills?.length ? store.prompt.skills : undefined, delivery, }) - .then( - () => undefined, - (error) => error, - ) - if (error) { - toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) - return false - } + .catch((error) => { + toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) + if (disposed || input.isDestroyed || input.plainText !== "") return + input.setText(entry.text) + setStore("prompt", entry) + setStore("mode", entry.mode ?? "normal") + restoreExtmarksFromPrompt(entry) + input.cursorOffset = entry.text.length + }) if (pendingEditorSelection) editor.markSelectionSent() } history.append({ @@ -1334,15 +1338,14 @@ export function Prompt(props: PromptProps) { setStore("extmarkToPart", new Map()) props.onSubmit?.() - // temporary hack to make sure the message is sent + // Optimistic admission puts the message in the store synchronously, so + // the session view renders it on arrival. if (!props.sessionID) { if (pendingEditorSelection) editor.preserveSelectionFromNewSession() - setTimeout(() => { - route.navigate({ - type: "session", - sessionID, - }) - }, 50) + route.navigate({ + type: "session", + sessionID, + }) } input.clear() if (finishMoveProgress) move.finishSubmit() diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 6531f61f58c4..e1eb763ea9d6 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -3211,8 +3211,9 @@ test("keeps the row when the response lands before the echo", async () => { } const calls = createFetch((url) => { if (url.pathname === `/api/session/${sessionID}/prompt`) return json({ data: admission }) - // The server's inbox listing still misses the admission (projection lag). + // The server's listings still miss the admission (projection lag). if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] }) + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) }, events) let sync!: ReturnType let ready!: () => void @@ -3242,11 +3243,14 @@ test("keeps the row when the response lands before the echo", async () => { await mounted await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) - // POST resolved but the echo has not arrived: a racing pending re-fetch - // still cannot wipe the row. + // POST resolved but the echo has not arrived: racing pending and message + // re-fetches still cannot wipe the row. await sync.session.pending.sync(sessionID) sync.session.pending.invalidate(sessionID) await sync.session.pending.sync(sessionID) + await sync.session.message.sync(sessionID) + sync.session.message.invalidate(sessionID) + await sync.session.message.sync(sessionID) expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) expect(sync.session.input.list(sessionID)).toEqual([messageID]) expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) From d6e91b0db23bc17678a191292676393f395648d7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 18:21:30 -0400 Subject: [PATCH 4/5] feat(tui): gate optimistic prompt sends behind an experiment Optimistic rendering, immediate navigation, and rollback-with-restore now sit behind the optimistic_prompt experiment (Experiments dialog, default off). With the flag off the submit flow keeps the previous awaited behavior, including the deferred navigation for new sessions. Client-minted prompt IDs stay unconditional: the POST always carries a stable ID, so the server's idempotent admission prevents duplicate sends on retry regardless of the experiment. data.session.prompt only admits optimistically when the caller opts in; the web app stays on the non-optimistic path. Verified live against a real server behind a latency/failure proxy: flag off reproduces stock behavior, flag on renders instantly, and the config watcher applies the toggle without restart. --- packages/client/src/solid/data.ts | 13 ++-- .../tui/src/component/dialog-experiments.tsx | 9 ++- packages/tui/src/component/prompt/index.tsx | 65 +++++++++++----- packages/tui/test/cli/tui/data.test.tsx | 78 +++++++++++++++++-- 4 files changed, 131 insertions(+), 34 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 3760f5137fd8..0402c1e5bb01 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1100,17 +1100,18 @@ export function createData(config: CreateDataInput) { sync.invalidate(`session.pending:${sessionID}`) }, }, - // Optimistic prompt admission: render the prompt immediately under a - // client-minted ID, send it, and let the durable inbox.enqueued echo - // upsert that same ID with the server's payload. Server admission is - // idempotent per ID, so retrying with the identical payload cannot - // double-admit. - prompt(input: SessionPromptInput) { + // Prompt admission under a client-minted ID: the server is idempotent + // per ID, so retrying with the identical payload cannot double-admit. + // With `optimistic` enabled the prompt also renders immediately and the + // durable inbox.enqueued echo upserts that same ID with the server's + // payload; otherwise the visible row waits for the echo as before. + prompt(input: SessionPromptInput, options?: { optimistic?: boolean }) { const id = input.id ?? SessionMessage.ID.create() // A retry may reuse an ID that is already rendered — and possibly // already durable. Admit optimistically only for new IDs so a failed // retry cannot roll back acknowledged state. const fresh = + (options?.optimistic ?? false) && !messageIndex.get(input.sessionID)?.has(id) && !store.session.pending[input.sessionID]?.some((item) => item.id === id) if (fresh) { diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index 8938ced5bc98..752c3e7f3ee5 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -13,7 +13,14 @@ type Experiment = { // In-flight features anyone can opt into. Each entry is temporary: an // experiment either graduates (delete the entry, make the behavior // unconditional) or dies (delete the entry and the branch it gated). -export const experiments: Experiment[] = [] +export const experiments: Experiment[] = [ + { + id: "optimistic_prompt", + title: "Instant prompt sends", + description: + "Render prompts immediately on submit instead of waiting for the server round-trip. Failed sends roll back and restore the composer.", + }, +] export function DialogExperiments() { const config = useConfig() diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 461cb39fc8c4..33e79e990d01 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1222,6 +1222,7 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode + const optimisticPrompt = config.experimental?.["optimistic_prompt"] === true if (store.mode === "shell") { move.startSubmit() void client.api.session.shell({ @@ -1304,21 +1305,22 @@ export function Prompt(props: PromptProps) { return false } } - // The data layer admits optimistically: the prompt renders immediately - // and rolls back if the server rejects it, so submission does not wait - // on the network. On rejection the row is already rolled back; restore - // the composer unless the user has started typing something new. - const entry = { ...store.prompt, mode: currentMode } - data.session - .prompt({ - sessionID, - text: inputText, - files: store.prompt.files, - agents: store.prompt.agents, - skills: store.prompt.skills?.length ? store.prompt.skills : undefined, - delivery, - }) - .catch((error) => { + const promptInput = { + sessionID, + text: inputText, + files: store.prompt.files, + agents: store.prompt.agents, + skills: store.prompt.skills?.length ? store.prompt.skills : undefined, + delivery, + } + if (optimisticPrompt) { + // The data layer admits optimistically: the prompt renders + // immediately and rolls back if the server rejects it, so submission + // does not wait on the network. On rejection the row is already + // rolled back; restore the composer unless the user has started + // typing something new. + const entry = { ...store.prompt, mode: currentMode } + data.session.prompt(promptInput, { optimistic: true }).catch((error) => { toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) if (disposed || input.isDestroyed || input.plainText !== "") return input.setText(entry.text) @@ -1327,6 +1329,17 @@ export function Prompt(props: PromptProps) { restoreExtmarksFromPrompt(entry) input.cursorOffset = entry.text.length }) + } + if (!optimisticPrompt) { + const error = await data.session.prompt(promptInput).then( + () => undefined, + (error) => error, + ) + if (error) { + toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) + return false + } + } if (pendingEditorSelection) editor.markSelectionSent() } history.append({ @@ -1338,14 +1351,24 @@ export function Prompt(props: PromptProps) { setStore("extmarkToPart", new Map()) props.onSubmit?.() - // Optimistic admission puts the message in the store synchronously, so - // the session view renders it on arrival. if (!props.sessionID) { if (pendingEditorSelection) editor.preserveSelectionFromNewSession() - route.navigate({ - type: "session", - sessionID, - }) + // Optimistic admission puts the message in the store synchronously, so + // the session view renders it on arrival. Without it, keep the delay + // that lets the send land before the view mounts. + if (optimisticPrompt) { + route.navigate({ + type: "session", + sessionID, + }) + } else { + setTimeout(() => { + route.navigate({ + type: "session", + sessionID, + }) + }, 50) + } } input.clear() if (finishMoveProgress) move.finishSubmit() diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index e1eb763ea9d6..11df6907c813 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -3133,7 +3133,7 @@ test("admits prompts optimistically and reconciles with the durable echo", async try { await mounted - const promise = sync.session.prompt({ sessionID, text: "hello" }) + const promise = sync.session.prompt({ sessionID, text: "hello" }, { optimistic: true }) const settled = promise.then( () => undefined, (error) => error, @@ -3241,7 +3241,7 @@ test("keeps the row when the response lands before the echo", async () => { try { await mounted - await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }, { optimistic: true }) // POST resolved but the echo has not arrived: racing pending and message // re-fetches still cannot wipe the row. @@ -3292,7 +3292,7 @@ test("rolls back an optimistic prompt the server rejected", async () => { try { await mounted - const promise = sync.session.prompt({ sessionID, text: "rejected" }) + const promise = sync.session.prompt({ sessionID, text: "rejected" }, { optimistic: true }) expect(sync.session.message.list(sessionID)).toHaveLength(1) await expect(promise).rejects.toThrow() @@ -3351,10 +3351,10 @@ test("a retry under the same client-minted ID cannot duplicate rows", async () = try { await mounted - await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }, { optimistic: true }) // Retry with the identical payload: server admission is idempotent per ID, // and the local dedupe keeps a single row. - await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }, { optimistic: true }) expect(posts).toEqual([messageID, messageID]) expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) @@ -3375,7 +3375,73 @@ test("a retry under the same client-minted ID cannot duplicate rows", async () = await wait(() => received.includes("session.inbox.enqueued")) unsubscribe() fail = true - await expect(sync.session.prompt({ sessionID, id: messageID, text: "hello" })).rejects.toThrow() + await expect(sync.session.prompt({ sessionID, id: messageID, text: "hello" }, { optimistic: true })).rejects.toThrow() + expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + } finally { + app.renderer.destroy() + } +}) + +test("does not admit optimistically unless opted in", async () => { + const events = createEventStream() + const sessionID = "session-1" + const messageID = "msg_plain_1" + const admission = { + id: messageID, + sessionID, + timeCreated: 1, + type: "user", + payload: { text: "hello" }, + delivery: "steer", + } + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/prompt`) return json({ data: admission }) + }, events) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + // Default path: the POST still carries the client-minted ID, but nothing + // renders until the durable echo. + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) + expect(sync.session.pending.list(sessionID)).toEqual([]) + expect(sync.session.input.list(sessionID)).toEqual([]) + expect(sync.session.message.list(sessionID)).toEqual([]) + + const received: string[] = [] + const unsubscribe = sync.listen((event) => received.push(event.name)) + emitEvent(events, { + id: "evt_plain_1", + created: 2, + type: "session.inbox.enqueued", + durable: durable(sessionID), + data: { sessionID, inboxID: messageID, item: { type: "user", payload: { text: "hello" }, delivery: "steer" } }, + }) + await wait(() => received.includes("session.inbox.enqueued")) + unsubscribe() expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) } finally { From 80862d88f1de8db0c7b0917cee5ac88e8d25c084 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 18:24:47 -0400 Subject: [PATCH 5/5] Revert "feat(tui): gate optimistic prompt sends behind an experiment" This reverts commit d6e91b0db23bc17678a191292676393f395648d7. --- packages/client/src/solid/data.ts | 13 ++-- .../tui/src/component/dialog-experiments.tsx | 9 +-- packages/tui/src/component/prompt/index.tsx | 65 +++++----------- packages/tui/test/cli/tui/data.test.tsx | 78 ++----------------- 4 files changed, 34 insertions(+), 131 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 0402c1e5bb01..3760f5137fd8 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1100,18 +1100,17 @@ export function createData(config: CreateDataInput) { sync.invalidate(`session.pending:${sessionID}`) }, }, - // Prompt admission under a client-minted ID: the server is idempotent - // per ID, so retrying with the identical payload cannot double-admit. - // With `optimistic` enabled the prompt also renders immediately and the - // durable inbox.enqueued echo upserts that same ID with the server's - // payload; otherwise the visible row waits for the echo as before. - prompt(input: SessionPromptInput, options?: { optimistic?: boolean }) { + // Optimistic prompt admission: render the prompt immediately under a + // client-minted ID, send it, and let the durable inbox.enqueued echo + // upsert that same ID with the server's payload. Server admission is + // idempotent per ID, so retrying with the identical payload cannot + // double-admit. + prompt(input: SessionPromptInput) { const id = input.id ?? SessionMessage.ID.create() // A retry may reuse an ID that is already rendered — and possibly // already durable. Admit optimistically only for new IDs so a failed // retry cannot roll back acknowledged state. const fresh = - (options?.optimistic ?? false) && !messageIndex.get(input.sessionID)?.has(id) && !store.session.pending[input.sessionID]?.some((item) => item.id === id) if (fresh) { diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index 752c3e7f3ee5..8938ced5bc98 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -13,14 +13,7 @@ type Experiment = { // In-flight features anyone can opt into. Each entry is temporary: an // experiment either graduates (delete the entry, make the behavior // unconditional) or dies (delete the entry and the branch it gated). -export const experiments: Experiment[] = [ - { - id: "optimistic_prompt", - title: "Instant prompt sends", - description: - "Render prompts immediately on submit instead of waiting for the server round-trip. Failed sends roll back and restore the composer.", - }, -] +export const experiments: Experiment[] = [] export function DialogExperiments() { const config = useConfig() diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 33e79e990d01..461cb39fc8c4 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1222,7 +1222,6 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode - const optimisticPrompt = config.experimental?.["optimistic_prompt"] === true if (store.mode === "shell") { move.startSubmit() void client.api.session.shell({ @@ -1305,22 +1304,21 @@ export function Prompt(props: PromptProps) { return false } } - const promptInput = { - sessionID, - text: inputText, - files: store.prompt.files, - agents: store.prompt.agents, - skills: store.prompt.skills?.length ? store.prompt.skills : undefined, - delivery, - } - if (optimisticPrompt) { - // The data layer admits optimistically: the prompt renders - // immediately and rolls back if the server rejects it, so submission - // does not wait on the network. On rejection the row is already - // rolled back; restore the composer unless the user has started - // typing something new. - const entry = { ...store.prompt, mode: currentMode } - data.session.prompt(promptInput, { optimistic: true }).catch((error) => { + // The data layer admits optimistically: the prompt renders immediately + // and rolls back if the server rejects it, so submission does not wait + // on the network. On rejection the row is already rolled back; restore + // the composer unless the user has started typing something new. + const entry = { ...store.prompt, mode: currentMode } + data.session + .prompt({ + sessionID, + text: inputText, + files: store.prompt.files, + agents: store.prompt.agents, + skills: store.prompt.skills?.length ? store.prompt.skills : undefined, + delivery, + }) + .catch((error) => { toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) if (disposed || input.isDestroyed || input.plainText !== "") return input.setText(entry.text) @@ -1329,17 +1327,6 @@ export function Prompt(props: PromptProps) { restoreExtmarksFromPrompt(entry) input.cursorOffset = entry.text.length }) - } - if (!optimisticPrompt) { - const error = await data.session.prompt(promptInput).then( - () => undefined, - (error) => error, - ) - if (error) { - toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) - return false - } - } if (pendingEditorSelection) editor.markSelectionSent() } history.append({ @@ -1351,24 +1338,14 @@ export function Prompt(props: PromptProps) { setStore("extmarkToPart", new Map()) props.onSubmit?.() + // Optimistic admission puts the message in the store synchronously, so + // the session view renders it on arrival. if (!props.sessionID) { if (pendingEditorSelection) editor.preserveSelectionFromNewSession() - // Optimistic admission puts the message in the store synchronously, so - // the session view renders it on arrival. Without it, keep the delay - // that lets the send land before the view mounts. - if (optimisticPrompt) { - route.navigate({ - type: "session", - sessionID, - }) - } else { - setTimeout(() => { - route.navigate({ - type: "session", - sessionID, - }) - }, 50) - } + route.navigate({ + type: "session", + sessionID, + }) } input.clear() if (finishMoveProgress) move.finishSubmit() diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 11df6907c813..e1eb763ea9d6 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -3133,7 +3133,7 @@ test("admits prompts optimistically and reconciles with the durable echo", async try { await mounted - const promise = sync.session.prompt({ sessionID, text: "hello" }, { optimistic: true }) + const promise = sync.session.prompt({ sessionID, text: "hello" }) const settled = promise.then( () => undefined, (error) => error, @@ -3241,7 +3241,7 @@ test("keeps the row when the response lands before the echo", async () => { try { await mounted - await sync.session.prompt({ sessionID, id: messageID, text: "hello" }, { optimistic: true }) + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) // POST resolved but the echo has not arrived: racing pending and message // re-fetches still cannot wipe the row. @@ -3292,7 +3292,7 @@ test("rolls back an optimistic prompt the server rejected", async () => { try { await mounted - const promise = sync.session.prompt({ sessionID, text: "rejected" }, { optimistic: true }) + const promise = sync.session.prompt({ sessionID, text: "rejected" }) expect(sync.session.message.list(sessionID)).toHaveLength(1) await expect(promise).rejects.toThrow() @@ -3351,10 +3351,10 @@ test("a retry under the same client-minted ID cannot duplicate rows", async () = try { await mounted - await sync.session.prompt({ sessionID, id: messageID, text: "hello" }, { optimistic: true }) + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) // Retry with the identical payload: server admission is idempotent per ID, // and the local dedupe keeps a single row. - await sync.session.prompt({ sessionID, id: messageID, text: "hello" }, { optimistic: true }) + await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) expect(posts).toEqual([messageID, messageID]) expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) @@ -3375,73 +3375,7 @@ test("a retry under the same client-minted ID cannot duplicate rows", async () = await wait(() => received.includes("session.inbox.enqueued")) unsubscribe() fail = true - await expect(sync.session.prompt({ sessionID, id: messageID, text: "hello" }, { optimistic: true })).rejects.toThrow() - expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) - expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) - } finally { - app.renderer.destroy() - } -}) - -test("does not admit optimistically unless opted in", async () => { - const events = createEventStream() - const sessionID = "session-1" - const messageID = "msg_plain_1" - const admission = { - id: messageID, - sessionID, - timeCreated: 1, - type: "user", - payload: { text: "hello" }, - delivery: "steer", - } - const calls = createFetch((url) => { - if (url.pathname === `/api/session/${sessionID}/prompt`) return json({ data: admission }) - }, events) - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useData() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - // Default path: the POST still carries the client-minted ID, but nothing - // renders until the durable echo. - await sync.session.prompt({ sessionID, id: messageID, text: "hello" }) - expect(sync.session.pending.list(sessionID)).toEqual([]) - expect(sync.session.input.list(sessionID)).toEqual([]) - expect(sync.session.message.list(sessionID)).toEqual([]) - - const received: string[] = [] - const unsubscribe = sync.listen((event) => received.push(event.name)) - emitEvent(events, { - id: "evt_plain_1", - created: 2, - type: "session.inbox.enqueued", - durable: durable(sessionID), - data: { sessionID, inboxID: messageID, item: { type: "user", payload: { text: "hello" }, delivery: "steer" } }, - }) - await wait(() => received.includes("session.inbox.enqueued")) - unsubscribe() + await expect(sync.session.prompt({ sessionID, id: messageID, text: "hello" })).rejects.toThrow() expect(sync.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) } finally {