Skip to content

Commit 2245bc5

Browse files
nexxelnCasualDeveloper
authored andcommitted
fix(tui): undo prompts the server already accepted
/undo restored a prompt whenever session.inbox.cancel returned 204. That cancel is a no-op when the prompt was already delivered, and it could reach the server before the prompt's own POST. Undo now runs in the per-session send chain, cancels, and reads the message back. When the message exists, it interrupts, waits, and stages the revert. Fixes anomalyco#54150
1 parent 7c5aef2 commit 2245bc5

2 files changed

Lines changed: 66 additions & 52 deletions

File tree

‎packages/client/src/solid/data.ts‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,10 @@ export function createData(config: CreateDataInput) {
404404
// to exist server-side instead of failing with "not found".
405405
const creating = new Map<string, Promise<unknown>>()
406406

407-
// Per-session send chain: prompts and compactions must be admitted in
408-
// submission order. Each waits for the previous POST to settle, so one
409-
// failure does not block the next.
407+
// Per-session send chain. Admissions, withdrawals, and undos run in
408+
// submission order, so a cancel cannot overtake the prompt it targets. Each
409+
// waits for the previous request to settle, so one failure does not block
410+
// the next.
410411
const sending = new Map<string, Promise<unknown>>()
411412
const messageLoads = new Map<string, Promise<unknown>>()
412413
const compacting = new Map<string, { id: string; observed: Set<string>; request: Promise<SessionInboxCompaction> }>()
@@ -423,7 +424,7 @@ export function createData(config: CreateDataInput) {
423424
}
424425

425426
// Capture creation before settlement clears its entry, so dependent RPCs still see a failed create.
426-
function sendAdmission<Value>(sessionID: string, send: () => Promise<Value>, gate?: Promise<unknown>) {
427+
function sendInOrder<Value>(sessionID: string, send: () => Promise<Value>, gate?: Promise<unknown>) {
427428
const created = creating.get(sessionID)
428429
const previous = sending.get(sessionID)
429430
const request = Promise.resolve()
@@ -437,6 +438,20 @@ export function createData(config: CreateDataInput) {
437438
return request
438439
}
439440

441+
// Cancellation is idempotent, so only the projected message shows whether delivery won.
442+
async function withdrawInput(sessionID: string, inboxID: string) {
443+
await api().session.inbox.cancel({ sessionID, inboxID })
444+
return api()
445+
.session.message.get({ sessionID, messageID: inboxID })
446+
.then(
447+
() => "delivered" as const,
448+
(error: unknown) => {
449+
if (isMessageNotFoundError(error)) return "withdrawn" as const
450+
throw error
451+
},
452+
)
453+
}
454+
440455
// Upsert an admitted inbox item into pending and (for user and synthetic
441456
// items) the visible transcript. Used by the inbox.enqueued
442457
// handler and by optimistic admission; the upsert is what reconciles
@@ -1599,6 +1614,9 @@ export function createData(config: CreateDataInput) {
15991614
store.session.pending[sessionID]?.some((item) => item.id === inboxID && item.type !== "compaction") ?? false
16001615
)
16011616
},
1617+
withdraw(input: { sessionID: string; inboxID: string }) {
1618+
return sendInOrder(input.sessionID, () => withdrawInput(input.sessionID, input.inboxID))
1619+
},
16021620
},
16031621
pending: {
16041622
list(sessionID: string) {
@@ -1743,7 +1761,7 @@ export function createData(config: CreateDataInput) {
17431761
// speculative row on an echo, and remember consumed IDs until the POST
17441762
// settles so its older response cannot resurrect a queued row.
17451763
const observed = new Set<string>()
1746-
const request = sendAdmission(input.sessionID, async () => {
1764+
const request = sendInOrder(input.sessionID, async () => {
17471765
if (input.model) await api().session.switchModel({ sessionID: input.sessionID, model: input.model })
17481766
return api().session.compact({ sessionID: input.sessionID, id })
17491767
})
@@ -1806,7 +1824,7 @@ export function createData(config: CreateDataInput) {
18061824
payload,
18071825
})
18081826
}
1809-
return sendAdmission(
1827+
return sendInOrder(
18101828
request.sessionID,
18111829
async () => {
18121830
const prepared = await prepare?.()
@@ -1827,6 +1845,19 @@ export function createData(config: CreateDataInput) {
18271845
throw error
18281846
})
18291847
},
1848+
// A loaded message that is no longer pending has left the inbox, so only a pending or unloaded one
1849+
// needs the server to decide.
1850+
undo(input: { sessionID: string; messageID: string }) {
1851+
return sendInOrder(input.sessionID, async () => {
1852+
const delivered =
1853+
!result.session.input.has(input.sessionID, input.messageID) &&
1854+
result.session.message.get(input.sessionID, input.messageID) !== undefined
1855+
if (!delivered && (await withdrawInput(input.sessionID, input.messageID)) === "withdrawn") return
1856+
await api().session.interrupt({ sessionID: input.sessionID })
1857+
await api().experimental.session.wait({ sessionID: input.sessionID })
1858+
await api().session.revert.stage({ sessionID: input.sessionID, messageID: input.messageID })
1859+
})
1860+
},
18301861
sync(sessionID: string, options?: { children?: boolean }) {
18311862
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
18321863
const started = deletionEpoch

‎packages/tui/src/routes/session/index.tsx‎

Lines changed: 29 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,7 @@ import { useTuiTerminalEnvironment } from "../../context/runtime"
2323
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
2424
import { PatchDiff } from "../../component/patch-diff"
2525
import { useTheme, useThemes } from "../../context/theme"
26-
import {
27-
BoxRenderable,
28-
ScrollBoxRenderable,
29-
addDefaultParsers,
30-
TextAttributes,
31-
RGBA,
32-
MouseEvent,
33-
} from "@opentui/core"
26+
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
3427
import { Prompt, type PromptRef } from "../../component/prompt"
3528
import {
3629
mediaHashFromUri,
@@ -693,13 +686,13 @@ export function Session(props: {
693686
const active = prompt()
694687
return Boolean(
695688
!sessionDisposed &&
696-
route.sessionID === event.data.sessionID &&
697-
active &&
698-
active.current.text === expectedText &&
699-
(active.current.files?.length ?? 0) === expectedFiles &&
700-
(active.current.agents?.length ?? 0) === expectedAgents &&
701-
(active.current.skills?.length ?? 0) === expectedSkills &&
702-
active.current.pasted.length === expectedPasted,
689+
route.sessionID === event.data.sessionID &&
690+
active &&
691+
active.current.text === expectedText &&
692+
(active.current.files?.length ?? 0) === expectedFiles &&
693+
(active.current.agents?.length ?? 0) === expectedAgents &&
694+
(active.current.skills?.length ?? 0) === expectedSkills &&
695+
active.current.pasted.length === expectedPasted,
703696
)
704697
}
705698
const canOpen = () =>
@@ -779,10 +772,13 @@ export function Session(props: {
779772
(await runPendingAction(inboxID, () => requestPending(action, inboxID))) ?? false
780773
// The cancel releases blobs nothing else references, so media refs load into the draft first. Resolves with the
781774
// composer it wrote to, if any, once the prompt is back; undefined if the cancel failed or was already running.
782-
const undoPending = (inboxID: string, message: ProjectedPrompt, failureLabel?: string) =>
775+
const undoPending = (inboxID: string, message: ProjectedPrompt) =>
783776
runPendingAction(inboxID, async () => {
784777
const restored = await restorePrompt(message)
785-
if (!(await requestPending("cancel", inboxID, failureLabel))) return
778+
if ((await data.session.input.withdraw({ sessionID, inboxID })) === "delivered") {
779+
toast.show({ message: "Prompt was already sent. Use /undo to take it back", variant: "warning" })
780+
return
781+
}
786782
return { composer: appendToSessionPrompt(sessionID, restored, renderer.widthMethod) }
787783
})
788784
const openQueuedPrompts = () =>
@@ -823,7 +819,7 @@ export function Session(props: {
823819
toast.show({ message: "Leave shell mode before undoing a queued prompt", variant: "error" })
824820
return
825821
}
826-
void undoPending(queued.id, queued.payload, "undo")
822+
void undoPending(queued.id, queued.payload)
827823
.then((undone) => {
828824
if (!undone || sessionDisposed) return
829825
dialog.clear()
@@ -1130,15 +1126,11 @@ export function Session(props: {
11301126
dialog.clear()
11311127
return
11321128
}
1133-
const undo = pendingDeliveries().has(message.id)
1134-
? undoPending(message.id, message)
1135-
: (async () => {
1136-
const restored = await restorePrompt(message)
1137-
await client.api.session.interrupt({ sessionID })
1138-
await client.api.experimental.session.wait({ sessionID })
1139-
await client.api.session.revert.stage({ sessionID, messageID: message.id })
1140-
appendToSessionPrompt(sessionID, restored, renderer.widthMethod)
1141-
})()
1129+
const undo = (async () => {
1130+
const restored = await restorePrompt(message)
1131+
await data.session.undo({ sessionID, messageID: message.id })
1132+
appendToSessionPrompt(sessionID, restored, renderer.widthMethod)
1133+
})()
11421134
void undo.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
11431135
dialog.clear()
11441136
},
@@ -1278,7 +1270,9 @@ export function Session(props: {
12781270
},
12791271
run: () => {
12801272
const lastAssistantMessage = messagesBeforeRevert().findLast(
1281-
(msg) => msg.type === "assistant" || (msg.type === "compaction" && msg.status === "completed" && msg.summary.trim() !== ""),
1273+
(msg) =>
1274+
msg.type === "assistant" ||
1275+
(msg.type === "compaction" && msg.status === "completed" && msg.summary.trim() !== ""),
12821276
)
12831277
if (!lastAssistantMessage) {
12841278
toast.show({ message: "No assistant messages found", variant: "error" })
@@ -1582,9 +1576,7 @@ export function Session(props: {
15821576
onMouseOut={() => setLatestHovered(false)}
15831577
onMouseUp={toBottom}
15841578
>
1585-
<text
1586-
fg={latestHovered() ? theme.text.action.secondary.hovered : theme.text.action.secondary.base}
1587-
>
1579+
<text fg={latestHovered() ? theme.text.action.secondary.hovered : theme.text.action.secondary.base}>
15881580
Jump to latest ↓
15891581
</text>
15901582
</box>
@@ -1630,12 +1622,7 @@ export function Session(props: {
16301622
}}
16311623
</Show>
16321624
</Match>
1633-
<Match
1634-
when={
1635-
session() &&
1636-
currentLocation.error?.location.directory === session()!.location.directory
1637-
}
1638-
>
1625+
<Match when={session() && currentLocation.error?.location.directory === session()!.location.directory}>
16391626
<SessionLocationMissing
16401627
directory={session()!.location.directory}
16411628
projectID={session()!.projectID}
@@ -2929,9 +2916,7 @@ function BlockTool(props: BlockToolProps) {
29292916
<Show
29302917
when={props.spinner}
29312918
fallback={
2932-
<text
2933-
fg={permission() ? theme.text.feedback.warning.base : (props.headerColor ?? theme.text.muted)}
2934-
>
2919+
<text fg={permission() ? theme.text.feedback.warning.base : (props.headerColor ?? theme.text.muted)}>
29352920
{title()}
29362921
</text>
29372922
}
@@ -3119,11 +3104,7 @@ function ShellDisplay(props: {
31193104
<Show
31203105
when={isRunning()}
31213106
fallback={
3122-
<text
3123-
fg={theme.text.base}
3124-
wrapMode={expanded() ? "word" : "char"}
3125-
maxHeight={expanded() ? undefined : 2}
3126-
>
3107+
<text fg={theme.text.base} wrapMode={expanded() ? "word" : "char"} maxHeight={expanded() ? undefined : 2}>
31273108
{limitedInput()}
31283109
</text>
31293110
}
@@ -3224,7 +3205,9 @@ function Read(props: ToolProps) {
32243205
Read {pathFormatter.format(stringValue(props.input.path))}
32253206
<Show when={props.input.offset !== undefined || props.input.limit !== undefined}>
32263207
:{finiteNumber(props.input.offset) || 1}-
3227-
{props.input.limit ? (finiteNumber(props.input.offset) || 1) + (finiteNumber(props.input.limit) || 0) - 1 : ""}
3208+
{props.input.limit
3209+
? (finiteNumber(props.input.offset) || 1) + (finiteNumber(props.input.limit) || 0) - 1
3210+
: ""}
32283211
</Show>
32293212
</InlineTool>
32303213
<For each={loaded()}>

0 commit comments

Comments
 (0)