fix: do not duplicate the active turn's user row on mid-run reload

While a turn is live the server can reconcile that turn's user row into the
transcript returned by /api/session (the sidecar/state.db merge picks it up
from state.db, where the agent writes it immediately) while
pending_user_message is still set. The row is then followed by the same
turn's assistant/tool rows.

_pendingCurrentTailUserMessage() scans the tail backwards and returns null as
soon as it meets a completed assistant, so in that state it reports "the
current turn has no user row". getPendingSessionMessage() then materializes
pending_user_message a second time and the pane renders two identical user
bubbles until the settle render replaces the list wholesale (which is why the
duplicate disappears when the answer lands, and why nothing wrong is ever
persisted).

Scanning past assistant/tool rows on text alone would regress #3300: a user
who submits the same text twice in a row legitimately gets two identical user
turns. Use pending_started_at as the discriminator instead — the server stamps
the active turn's user row with it, and no earlier turn can share it. Text
equality is still required, so a false match needs identical text AND a
near-identical timestamp. Absent/invalid pending_started_at disables the
fallback and keeps the previous strict-tail behaviour.

Verified: the new midRunReloadDedupe assertion fails on the unpatched ui.js
and passes with the fix; the existing repeat-prompt, compaction-boundary and
strict-tail assertions still pass.
This commit is contained in:
happy5318 committed 2026-08-01 12:07:45 +08:00
1 parent 320789ae59
commit 55af555846
2 files changed
+145 -8

No files matched your search

+75 -8
View File
@@ -10365,22 +10365,89 @@ 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;
function _messageTimestampSeconds(msg){
if(!msg) return null;
const raw=msg._ts!=null?msg._ts:msg.timestamp;
const value=Number(raw);
return Number.isFinite(value)&&value>0?value:null;
}
/**
* Find the active turn's user row even when the current turn's output already
* follows it in the transcript.
*
* While a turn is live the server can reconcile the current user row into the
* returned transcript (the sidecar/state.db merge picks it up from state.db,
* where the agent writes it immediately) while `pending_user_message` is still
* set. The row is then followed by that same turn's assistant/tool rows, so the
* strict tail scan in `_pendingCurrentTailUserMessage` stops at the completed
* assistant and reports "no current user row" — and the caller materializes the
* pending prompt a SECOND time, rendering a duplicate user bubble until the
* settle render replaces the list.
*
* 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.
*/
function _pendingActiveTurnUserMessage(messages, session){
const startedAt=Number(session?.pending_started_at);
if(!Number.isFinite(startedAt)||startedAt<=0) return null;
const list=Array.isArray(messages)?messages:[];
for(let i=list.length-1;i>=0;i--){
const msg=list[i];
if(!msg||String(msg.role||'')!=='user') continue;
if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(msg)) continue;
const ts=_messageTimestampSeconds(msg);
if(ts===null) continue;
if(Math.abs(ts-startedAt)<=_PENDING_ACTIVE_TURN_TS_TOLERANCE) return msg;
}
return null;
}
function getPendingSessionMessage(session, messagesOverride=null){
const text=String(session?.pending_user_message||'').trim();
if(!text) return null;
const attachments=Array.isArray(session?.pending_attachments)?session.pending_attachments.filter(Boolean):[];
const sourceMessages=Array.isArray(messagesOverride)?messagesOverride:session?.messages;
const messages=Array.isArray(sourceMessages)?sourceMessages:[];
const pendingCandidate={role:'user',content:text};
const _matchesPending=(row)=>{
if(!row) return false;
return typeof _sameTranscriptMessage==='function'
? _sameTranscriptMessage(row,pendingCandidate)
: String(msgContent(row)||'').trim()===text;
};
const _adoptExistingRow=(row)=>{
if(attachments.length&&!row.attachments?.length) row.attachments=attachments;
return null;
};
const currentTailUser=_pendingCurrentTailUserMessage(messages);
if(currentTailUser){
const pendingCandidate={role:'user',content:text};
const sameCurrentTurn=typeof _sameTranscriptMessage==='function'
? _sameTranscriptMessage(currentTailUser,pendingCandidate)
: String(msgContent(currentTailUser)||'').trim()===text;
if(sameCurrentTurn){
if(attachments.length&&!currentTailUser.attachments?.length) currentTailUser.attachments=attachments;
return null;
}
const sameCurrentTurn=_matchesPending(currentTailUser);
if(sameCurrentTurn) return _adoptExistingRow(currentTailUser);
}
// Fallback: the current turn's user row is already in the transcript but the
// strict tail scan above could not see it because this turn's assistant/tool
// output follows it. Matched by pending_started_at, so previous turns that
// repeat the same text are unaffected. Guarded with typeof so a partial load
// (or a static probe that extracts only some helpers) degrades to the
// original strict-tail behaviour instead of throwing.
const activeTurnUser=typeof _pendingActiveTurnUserMessage==='function'
? _pendingActiveTurnUserMessage(messages,session)
: null;
if(activeTurnUser&&activeTurnUser!==currentTailUser&&_matchesPending(activeTurnUser)){
return _adoptExistingRow(activeTurnUser);
}
return {
role:'user',
+70
View File
@@ -24,6 +24,20 @@ def _function_body(src: str, signature: str) -> str:
raise AssertionError(f"could not extract function body for {signature!r}")
def _optional_function_body(src: str, signature: str) -> str:
"""Extract a helper if present, else return "".
Used for helpers whose ABSENCE is itself the regression under test: the
probe must still run so the behavioral assertion reports the duplicate,
rather than dying with a substring-not-found extraction error that says
nothing about observable behavior.
"""
try:
return _function_body(src, signature)
except (ValueError, AssertionError):
return ""
def _run_session_identity_probe() -> dict:
prompt = "same submitted prompt\nwith a second line"
workspace_prompt = f"[Workspace::v1: /tmp/hermes-webui]\n{prompt}"
@@ -197,12 +211,15 @@ def _run_pending_session_message_probe() -> dict:
_function_body(SESSIONS_SRC, "function _normalizeUserTranscriptText"),
_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 _pendingActiveTurnUserMessage"),
_function_body(UI_SRC, "function _isContextCompactionText"),
_function_body(UI_SRC, "function _isContextCompactionMessage"),
_function_body(UI_SRC, "function getPendingSessionMessage"),
]
)
script = f"""
const _PENDING_ACTIVE_TURN_TS_TOLERANCE=1.5;
{helpers}
const prompt = {json.dumps(prompt)};
const historical = {{role:'user', content:prompt, _ts:1}};
@@ -270,6 +287,50 @@ const repeatedCompletedPromptCount = repeatedCompletedMessages.filter(
).length;
const compactionCurrentTail = _pendingCurrentTailUserMessage([historical, historicalAnswer, currentTailForCompaction, compactionMarker]);
// Mid-run reload: the server already reconciled the active turn's user row into
// the transcript AND this turn's assistant/tool output follows it, while
// pending_user_message is still set. The strict tail scan cannot see the user
// row (it stops at the completed assistant), so without the pending_started_at
// fallback the prompt is materialized a second time -> duplicate user bubble.
const midRunStartedAt = 500;
const midRunTranscript = [
{{role:'user', content:'earlier question', timestamp:100}},
{{role:'assistant', content:'earlier answer', timestamp:200}},
{{role:'user', content:prompt, timestamp:midRunStartedAt}},
{{role:'assistant', content:'', timestamp:midRunStartedAt+15}},
{{role:'tool', content:'{{"ok":true}}', timestamp:midRunStartedAt+16}},
{{role:'assistant', content:'partial answer', timestamp:midRunStartedAt+60}},
];
const midRunResult = getPendingSessionMessage(
{{pending_user_message:prompt, pending_started_at:midRunStartedAt}},
midRunTranscript
);
const midRunWorkspaceTranscript = [
{{role:'user', content:'earlier question', timestamp:100}},
{{role:'assistant', content:'earlier answer', timestamp:200}},
{{role:'user', content:{json.dumps(current_workspace_prompt)}, timestamp:midRunStartedAt}},
{{role:'assistant', content:'partial answer', timestamp:midRunStartedAt+30}},
];
const midRunWorkspaceResult = getPendingSessionMessage(
{{pending_user_message:prompt, pending_started_at:midRunStartedAt}},
midRunWorkspaceTranscript
);
// A same-text row from an OLDER turn must never be adopted: only the row whose
// timestamp matches pending_started_at identifies the active turn.
const staleSameTextTranscript = [
{{role:'user', content:prompt, timestamp:midRunStartedAt-600}},
{{role:'assistant', content:'old answer', timestamp:midRunStartedAt-500}},
];
const staleSameTextResult = getPendingSessionMessage(
{{pending_user_message:prompt, pending_started_at:midRunStartedAt}},
staleSameTextTranscript
);
// Legacy/absent pending_started_at disables the fallback rather than guessing.
const noStartedAtResult = getPendingSessionMessage(
{{pending_user_message:prompt}},
midRunTranscript
);
process.stdout.write(JSON.stringify({{
historicalSameTextSurvives: !!fromHistoricalSameText && fromHistoricalSameText.content===prompt && fromHistoricalSameText._pending===true,
historicalWorkspaceSurvives: !!fromHistoricalWorkspace && fromHistoricalWorkspace.content===prompt && fromHistoricalWorkspace._pending===true,
@@ -282,6 +343,10 @@ process.stdout.write(JSON.stringify({{
compactionBoundaryCurrentTail: compactionCurrentTail&&compactionCurrentTail.role==='user'&&compactionCurrentTail.content===prompt,
compactionCurrentTailAttachmentsCopied: Array.isArray(currentTailForCompaction.attachments) && currentTailForCompaction.attachments[0].name==='note.txt',
repeatedCompletedPromptsRemainValid: repeatedCompletedPromptCount===3,
midRunReloadDedupe: midRunResult===null,
midRunWorkspaceReloadDedupe: midRunWorkspaceResult===null,
staleSameTextStillMaterializes: !!staleSameTextResult && staleSameTextResult._pending===true,
legacyNoStartedAtStillMaterializes: !!noStartedAtResult && noStartedAtResult._pending===true,
isContextCompactionText: _isContextCompactionText(compactionMarker.content),
isContextCompactionMessage: _isContextCompactionMessage(compactionMarker),
}}));
@@ -496,6 +561,11 @@ def test_get_pending_session_message_keeps_deferred_repeat_prompt_by_behavior():
assert result["compactionBoundaryCurrentTail"] is True
assert result["compactionCurrentTailAttachmentsCopied"] is True
assert result["repeatedCompletedPromptsRemainValid"] is True
# #6649 follow-up: mid-run reload must not duplicate the active turn's user row
assert result["midRunReloadDedupe"] is True
assert result["midRunWorkspaceReloadDedupe"] is True
assert result["staleSameTextStillMaterializes"] is True
assert result["legacyNoStartedAtStillMaterializes"] is True
assert result["isContextCompactionText"] is True
assert result["isContextCompactionMessage"] is True