fix: match active turn by exact identity (token/epsilon), not 1.5s proximity

Review round 2: the 1.5s timestamp tolerance over-deduped a legitimate
rapid repeat — two identical-text turns <1.5s apart collapsed into one,
hiding the second turn and copying its attachments onto the earlier row
(the mirror risk of #6649).

_pendingActiveTurnUserMessage now matches only on unambiguous identity:
- the row carries the server-stamped _active_turn_token (stream_id +
  started_at, per build_active_turn_token), or
- its timestamp equals pending_started_at within a precision-only
  epsilon (1e-6, absorbs float/state.db drift, never a full second).

Anything wider (whole-second truncation, ~1s rapid repeat) returns null
so getPendingSessionMessage() materializes the pending turn — fail
toward the harmless transient duplicate the settle render clears.

Regressions added: two completed identical-text turns ~1s apart → second
pending row returned + first row's attachments untouched; token-identity
row adopted even when its timestamp is outside the epsilon.
This commit is contained in:
happy5318 committed 2026-08-03 13:46:56 +08:00
1 parent 55af555846
commit 5cda58fc9b
2 files changed
+85 -13

No files matched your search

+49 -12
View File
@@ -10365,12 +10365,16 @@ function _pendingCurrentTailUserMessage(messages){
return null; return null;
} }
// Tolerance when matching a transcript row against session.pending_started_at. // Precision-only epsilon when matching a transcript row against
// Some persistence paths stamp the active turn's user row with the exact float // session.pending_started_at. The server stamps the active turn's user row with
// started_at, others truncate to whole seconds (observed drift up to ~0.4s), and // the exact pending_started_at float; state.db round-trips can lose
// state.db round-trips lose sub-microsecond precision. 1.5s absorbs all of those // sub-microsecond precision, so 1e-6 absorbs float drift without ever treating a
// while staying far below the gap between two genuinely separate turns. // whole-second (or sub-second) difference as identity. Anything wider is
const _PENDING_ACTIVE_TURN_TS_TOLERANCE=1.5; // 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){ function _messageTimestampSeconds(msg){
if(!msg) return null; if(!msg) return null;
@@ -10379,6 +10383,26 @@ function _messageTimestampSeconds(msg){
return Number.isFinite(value)&&value>0?value:null; 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 * Find the active turn's user row even when the current turn's output already
* follows it in the transcript. * 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 * 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 * 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 * identical user turns, and matching on text would swallow the new one. The
* discriminator here is the TIMESTAMP: the server stamps the active turn's user * discriminator is therefore exact identity, never proximity: the active turn's
* row with `pending_started_at`, which no earlier turn can share. Text equality * row either carries the server-stamped `_active_turn_token` (stream_id +
* is still required, so a false match needs identical text AND a near-identical * started_at — unique to this turn), or its timestamp equals `pending_started_at`
* timestamp. * 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){ function _pendingActiveTurnUserMessage(messages, session){
const startedAt=Number(session?.pending_started_at); const startedAt=Number(session?.pending_started_at);
@@ -10408,10 +10437,18 @@ function _pendingActiveTurnUserMessage(messages, session){
const msg=list[i]; const msg=list[i];
if(!msg||String(msg.role||'')!=='user') continue; if(!msg||String(msg.role||'')!=='user') continue;
if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(msg)) 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); const ts=_messageTimestampSeconds(msg);
if(ts===null) continue; 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; return null;
} }
+36 -1
View File
@@ -212,6 +212,7 @@ def _run_pending_session_message_probe() -> dict:
_function_body(SESSIONS_SRC, "function _sameTranscriptMessage"), _function_body(SESSIONS_SRC, "function _sameTranscriptMessage"),
_function_body(UI_SRC, "function _pendingCurrentTailUserMessage"), _function_body(UI_SRC, "function _pendingCurrentTailUserMessage"),
_optional_function_body(UI_SRC, "function _messageTimestampSeconds"), _optional_function_body(UI_SRC, "function _messageTimestampSeconds"),
_optional_function_body(UI_SRC, "function _activeTurnTokenMatches"),
_optional_function_body(UI_SRC, "function _pendingActiveTurnUserMessage"), _optional_function_body(UI_SRC, "function _pendingActiveTurnUserMessage"),
_function_body(UI_SRC, "function _isContextCompactionText"), _function_body(UI_SRC, "function _isContextCompactionText"),
_function_body(UI_SRC, "function _isContextCompactionMessage"), _function_body(UI_SRC, "function _isContextCompactionMessage"),
@@ -219,7 +220,7 @@ def _run_pending_session_message_probe() -> dict:
] ]
) )
script = f""" script = f"""
const _PENDING_ACTIVE_TURN_TS_TOLERANCE=1.5; const _PENDING_ACTIVE_TURN_TS_EPSILON=1e-6;
{helpers} {helpers}
const prompt = {json.dumps(prompt)}; const prompt = {json.dumps(prompt)};
const historical = {{role:'user', content:prompt, _ts:1}}; const historical = {{role:'user', content:prompt, _ts:1}};
@@ -331,6 +332,32 @@ const noStartedAtResult = getPendingSessionMessage(
midRunTranscript 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({{ process.stdout.write(JSON.stringify({{
historicalSameTextSurvives: !!fromHistoricalSameText && fromHistoricalSameText.content===prompt && fromHistoricalSameText._pending===true, historicalSameTextSurvives: !!fromHistoricalSameText && fromHistoricalSameText.content===prompt && fromHistoricalSameText._pending===true,
historicalWorkspaceSurvives: !!fromHistoricalWorkspace && fromHistoricalWorkspace.content===prompt && fromHistoricalWorkspace._pending===true, historicalWorkspaceSurvives: !!fromHistoricalWorkspace && fromHistoricalWorkspace.content===prompt && fromHistoricalWorkspace._pending===true,
@@ -347,6 +374,9 @@ process.stdout.write(JSON.stringify({{
midRunWorkspaceReloadDedupe: midRunWorkspaceResult===null, midRunWorkspaceReloadDedupe: midRunWorkspaceResult===null,
staleSameTextStillMaterializes: !!staleSameTextResult && staleSameTextResult._pending===true, staleSameTextStillMaterializes: !!staleSameTextResult && staleSameTextResult._pending===true,
legacyNoStartedAtStillMaterializes: !!noStartedAtResult && noStartedAtResult._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), isContextCompactionText: _isContextCompactionText(compactionMarker.content),
isContextCompactionMessage: _isContextCompactionMessage(compactionMarker), 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["midRunWorkspaceReloadDedupe"] is True
assert result["staleSameTextStillMaterializes"] is True assert result["staleSameTextStillMaterializes"] is True
assert result["legacyNoStartedAtStillMaterializes"] 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["isContextCompactionText"] is True
assert result["isContextCompactionMessage"] is True assert result["isContextCompactionMessage"] is True