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:
1 parent
55af555846
commit
5cda58fc9b
2 files changed
+85
-13
No files matched your search
+49
-12
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user