From 819e01c134f0d278cb00afff5bc7582f24e0eac2 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Fri, 24 Jul 2026 13:40:25 +0700 Subject: [PATCH] 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. --- .../src/app/chat/session-tile-actions.ts | 9 +++-- .../session/hooks/use-prompt-actions/index.ts | 9 +++-- .../hooks/use-prompt-actions/rewind.ts | 19 +++++++++- tui_gateway/server.py | 35 +++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 4eca47f5c094..ac683a291a7a 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -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) { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1e314fe714a8..18b080bd3b54 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -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) { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts index 3f511f004ebd..32dc25c1ebf7 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts @@ -25,6 +25,23 @@ import { type RequestGateway = (method: string, params?: Record, timeoutMs?: number) => Promise +/** + * 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 { + 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 ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f4a7c3b12f54..432fb8eae72d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: