fix(tui): refuse empty prompt.submit truncation without confirm

Stale truncate_before_user_ordinal=0 from a desynced Desktop client
resolved to history[:0] and replace_messages() wiped the durable
transcript. Require confirm_empty_truncate for that edge and have
intentional first-turn restore/regenerate paths send it.
This commit is contained in:
HexLab98 2026-07-24 13:40:25 +07:00 committed by kshitij
parent f7e2c0e2e2
commit 819e01c134
4 changed files with 67 additions and 5 deletions

View file

@ -38,7 +38,8 @@ import {
planEdit,
planReload,
planRestore,
runRewindSubmit
runRewindSubmit,
truncateSubmitParams
} from '../session/hooks/use-prompt-actions/rewind'
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
@ -274,7 +275,11 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
try {
await requestGateway(
'prompt.submit',
{ session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
{
session_id: runtimeIdRef.current,
text: plan.text,
...truncateSubmitParams(plan.truncateOrdinal)
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {

View file

@ -53,7 +53,8 @@ import {
planEdit,
planReload,
planRestore,
runRewindSubmit
runRewindSubmit,
truncateSubmitParams
} from './rewind'
import { useSlashCommand } from './slash'
import { useSubmitPrompt } from './submit'
@ -756,7 +757,11 @@ export function usePromptActions({
try {
await requestGateway(
'prompt.submit',
{ session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
{
session_id: activeSessionId,
text: plan.text,
...truncateSubmitParams(plan.truncateOrdinal)
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {

View file

@ -25,6 +25,23 @@ import {
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
/**
* Build `prompt.submit` truncation params. Ordinal 0 truncates to an empty
* transcript (restore/regenerate the first user turn) the gateway refuses
* that edge unless `confirm_empty_truncate` is set, so stale clients cannot
* silently wipe a session via a leftover ordinal.
*/
export function truncateSubmitParams(truncateOrdinal: number | undefined): Record<string, unknown> {
if (truncateOrdinal === undefined) {
return {}
}
return {
truncate_before_user_ordinal: truncateOrdinal,
...(truncateOrdinal === 0 ? { confirm_empty_truncate: true } : {})
}
}
/**
* Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal`
* (drops that user turn + everything after). Idle rewinds submit directly
@ -53,7 +70,7 @@ export async function runRewindSubmit(
{
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
...truncateSubmitParams(truncateOrdinal)
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)

View file

@ -10225,6 +10225,41 @@ def _(rid, params: dict) -> dict:
if ordinal < 0 or ordinal >= len(user_indices):
return _err(rid, 4018, "target user message is no longer in session history")
truncated = history[: user_indices[ordinal]]
# Stale clients can attach truncate_before_user_ordinal=0 to an
# ordinary submit. That resolves to history[:0] == [] and
# replace_messages() DELETEs every durable row — silent total
# transcript loss. Refuse the empty-truncation edge unless the
# client explicitly opts in (legitimate restore/regenerate of the
# first user turn).
if (
not truncated
and history
and not is_truthy_value(params.get("confirm_empty_truncate"))
):
logger.warning(
"prompt.submit: REFUSED empty truncation of session %s "
"(%d messages would be wiped; ordinal=%d).",
sid,
len(history),
ordinal,
)
return _err(
rid,
4025,
"truncation would erase the entire session transcript; "
"resubmit with confirm_empty_truncate=true if this is intended",
)
# Info for routine rewind/edit cuts; warning only when the client
# explicitly opts into wiping the whole transcript.
log_fn = logger.warning if not truncated else logger.info
log_fn(
"prompt.submit: truncating session %s history %d -> %d messages "
"(ordinal=%d)",
sid,
len(history),
len(truncated),
ordinal,
)
session["history"] = truncated
session["history_version"] = int(session.get("history_version", 0)) + 1
if (db := _get_db()) is not None: