diff --git a/static/ui.js b/static/ui.js index 5e45f95f..2c18028c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10365,12 +10365,16 @@ function _pendingCurrentTailUserMessage(messages){ return null; } -// Tolerance when matching a transcript row against session.pending_started_at. -// Some persistence paths stamp the active turn's user row with the exact float -// started_at, others truncate to whole seconds (observed drift up to ~0.4s), and -// state.db round-trips lose sub-microsecond precision. 1.5s absorbs all of those -// while staying far below the gap between two genuinely separate turns. -const _PENDING_ACTIVE_TURN_TS_TOLERANCE=1.5; +// Precision-only epsilon when matching a transcript row against +// session.pending_started_at. The server stamps the active turn's user row with +// the exact pending_started_at float; state.db round-trips can lose +// sub-microsecond precision, so 1e-6 absorbs float drift without ever treating a +// whole-second (or sub-second) difference as identity. Anything wider is +// ambiguous — a fast "继续"/"continue" double-send lands ~1s after the previous +// turn — and must NOT match: fail toward materializing the pending turn (a +// harmless transient duplicate the settle render clears) rather than hiding a +// turn and moving its attachments onto an earlier row. +const _PENDING_ACTIVE_TURN_TS_EPSILON=1e-6; function _messageTimestampSeconds(msg){ if(!msg) return null; @@ -10379,6 +10383,26 @@ function _messageTimestampSeconds(msg){ return Number.isFinite(value)&&value>0?value:null; } +/** + * Exact-identity match for the active turn's user row via its + * `_active_turn_token`, mirroring the server's `build_active_turn_token` + * ("{stream_id}:{started_at}") stamped by the eager-checkpoint path. The token + * embeds the stream_id, which no other turn can share, so a row carrying the + * current session's token IS the active turn — no timestamp tolerance needed. + */ +function _activeTurnTokenMatches(msg, session){ + if(!msg||typeof msg._active_turn_token!=='string') return false; + const streamId=session&&session.active_stream_id; + const startedAt=Number(session&&session.pending_started_at); + if(!streamId||!Number.isFinite(startedAt)||startedAt<=0) return false; + const sep=msg._active_turn_token.lastIndexOf(':'); + if(sep<=0) return false; + if(msg._active_turn_token.slice(0,sep).trim()!==String(streamId).trim()) return false; + const tokenStarted=Number(msg._active_turn_token.slice(sep+1)); + return Number.isFinite(tokenStarted)&&tokenStarted>0 + && Math.abs(tokenStarted-startedAt)<=_PENDING_ACTIVE_TURN_TS_EPSILON; +} + /** * Find the active turn's user row even when the current turn's output already * follows it in the transcript. @@ -10394,11 +10418,16 @@ function _messageTimestampSeconds(msg){ * * Scanning past assistant/tool rows alone would be wrong: a user who submits the * same text twice in a row (a plain "继续" follow-up) legitimately gets two - * identical user turns, and matching on text would swallow the new one. So the - * discriminator here is the TIMESTAMP: the server stamps the active turn's user - * row with `pending_started_at`, which no earlier turn can share. Text equality - * is still required, so a false match needs identical text AND a near-identical - * timestamp. + * identical user turns, and matching on text would swallow the new one. The + * discriminator is therefore exact identity, never proximity: the active turn's + * row either carries the server-stamped `_active_turn_token` (stream_id + + * started_at — unique to this turn), or its timestamp equals `pending_started_at` + * within a precision-only epsilon that absorbs float/state.db drift but never a + * full second. A whole-second (or sub-second) mismatch is ambiguous and returns + * null so the caller materializes the pending turn — the transient duplicate is + * harmless, hiding a turn + moving its attachments is not. Text equality is + * still required downstream, so a false match needs identical text AND an exact + * identity signal. */ function _pendingActiveTurnUserMessage(messages, session){ const startedAt=Number(session?.pending_started_at); @@ -10408,10 +10437,18 @@ function _pendingActiveTurnUserMessage(messages, session){ const msg=list[i]; if(!msg||String(msg.role||'')!=='user') continue; if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(msg)) continue; + // Unambiguous: the row carries the active turn's exact token + // (stream_id + started_at) stamped by the server's eager-checkpoint path. + if(typeof _activeTurnTokenMatches==='function'&&_activeTurnTokenMatches(msg,session)) return msg; + // Unambiguous: the row's timestamp IS pending_started_at within + // precision-only float drift (never a whole second). const ts=_messageTimestampSeconds(msg); if(ts===null) continue; - if(Math.abs(ts-startedAt)<=_PENDING_ACTIVE_TURN_TS_TOLERANCE) return msg; + if(Math.abs(ts-startedAt)<=_PENDING_ACTIVE_TURN_TS_EPSILON) return msg; } + // Any wider drift (whole-second truncation, a rapid repeat ~1s later) is + // ambiguous: return null so getPendingSessionMessage() materializes the + // pending turn rather than guessing. return null; } diff --git a/tests/test_run_journal_frontend_static.py b/tests/test_run_journal_frontend_static.py index 81793bc8..80588f3c 100644 --- a/tests/test_run_journal_frontend_static.py +++ b/tests/test_run_journal_frontend_static.py @@ -212,6 +212,7 @@ def _run_pending_session_message_probe() -> dict: _function_body(SESSIONS_SRC, "function _sameTranscriptMessage"), _function_body(UI_SRC, "function _pendingCurrentTailUserMessage"), _optional_function_body(UI_SRC, "function _messageTimestampSeconds"), + _optional_function_body(UI_SRC, "function _activeTurnTokenMatches"), _optional_function_body(UI_SRC, "function _pendingActiveTurnUserMessage"), _function_body(UI_SRC, "function _isContextCompactionText"), _function_body(UI_SRC, "function _isContextCompactionMessage"), @@ -219,7 +220,7 @@ def _run_pending_session_message_probe() -> dict: ] ) script = f""" -const _PENDING_ACTIVE_TURN_TS_TOLERANCE=1.5; +const _PENDING_ACTIVE_TURN_TS_EPSILON=1e-6; {helpers} const prompt = {json.dumps(prompt)}; const historical = {{role:'user', content:prompt, _ts:1}}; @@ -331,6 +332,32 @@ const noStartedAtResult = getPendingSessionMessage( midRunTranscript ); +// Regression (review round 2): two completed identical-text turns whose +// started_at differ by ~1s. The old 1.5s tolerance adopted the EARLIER row as +// the "active turn", hiding the second (still-pending) turn and copying its +// attachments onto the old row. With the precision-only epsilon the second turn +// is materialized and the first row stays untouched. +const rapidRepeatTurnOne = {{role:'user', content:prompt, timestamp:100}}; +const rapidRepeatAnswerOne = {{role:'assistant', content:'done', timestamp:101}}; +const rapidRepeatSecondAttachments = [{{name:'second.txt', path:'second.txt', mime:'text/plain'}}]; +const rapidRepeatResult = getPendingSessionMessage( + {{pending_user_message:prompt, pending_started_at:101, pending_attachments:rapidRepeatSecondAttachments}}, + [rapidRepeatTurnOne, rapidRepeatAnswerOne] +); +// Exact token identity: the eager-checkpoint row carries _active_turn_token +// built from the same stream_id + started_at as the session — adopted even +// though its timestamp (499.9) is NOT within the epsilon of pending_started_at. +const tokenIdentityTranscript = [ + {{role:'user', content:'earlier question', timestamp:100}}, + {{role:'assistant', content:'earlier answer', timestamp:200}}, + {{role:'user', content:prompt, timestamp:499.9, _active_turn_token:'stream-abc:500'}}, + {{role:'assistant', content:'partial', timestamp:560}}, +]; +const tokenIdentityResult = getPendingSessionMessage( + {{pending_user_message:prompt, pending_started_at:midRunStartedAt, active_stream_id:'stream-abc'}}, + tokenIdentityTranscript +); + process.stdout.write(JSON.stringify({{ historicalSameTextSurvives: !!fromHistoricalSameText && fromHistoricalSameText.content===prompt && fromHistoricalSameText._pending===true, historicalWorkspaceSurvives: !!fromHistoricalWorkspace && fromHistoricalWorkspace.content===prompt && fromHistoricalWorkspace._pending===true, @@ -347,6 +374,9 @@ process.stdout.write(JSON.stringify({{ midRunWorkspaceReloadDedupe: midRunWorkspaceResult===null, staleSameTextStillMaterializes: !!staleSameTextResult && staleSameTextResult._pending===true, legacyNoStartedAtStillMaterializes: !!noStartedAtResult && noStartedAtResult._pending===true, + rapidRepeatSecondTurnReturned: !!rapidRepeatResult && rapidRepeatResult._pending===true && rapidRepeatResult.content===prompt && Array.isArray(rapidRepeatResult.attachments) && rapidRepeatResult.attachments[0].name==='second.txt', + rapidRepeatFirstRowKeptClean: !Array.isArray(rapidRepeatTurnOne.attachments), + tokenIdentityDedupe: tokenIdentityResult===null, isContextCompactionText: _isContextCompactionText(compactionMarker.content), isContextCompactionMessage: _isContextCompactionMessage(compactionMarker), }})); @@ -566,6 +596,11 @@ def test_get_pending_session_message_keeps_deferred_repeat_prompt_by_behavior(): assert result["midRunWorkspaceReloadDedupe"] is True assert result["staleSameTextStillMaterializes"] is True assert result["legacyNoStartedAtStillMaterializes"] is True + # #6670 review round 2: ~1s-apart identical turns must not be collapsed by + # the active-turn fallback (no over-dedup, no attachment mis-attachment). + assert result["rapidRepeatSecondTurnReturned"] is True + assert result["rapidRepeatFirstRowKeptClean"] is True + assert result["tokenIdentityDedupe"] is True assert result["isContextCompactionText"] is True assert result["isContextCompactionMessage"] is True