Harden active user turn dedupe tests

This commit is contained in:
Frank Song 2026-06-24 16:31:45 +08:00 committed by nesquena-hermes
parent 709f133d76
commit 035a2609f1
3 changed files with 86 additions and 35 deletions

View file

@ -2339,7 +2339,11 @@ function _stripForcedSkillEnvelope(text){
}
function _normalizeUserTranscriptText(text){
let value=_stripAttachedFilesMarker(_stripForcedSkillEnvelope(text));
const value=_stripAttachedFilesMarker(_stripForcedSkillEnvelope(text));
// ui.js is loaded before sessions.js in index.html and owns the canonical
// workspace-sentinel parser used by rendering. Keep a small fallback for
// static/helper tests and defensive partial loads, but prefer the renderer's
// parser whenever it is available.
if(typeof _stripWorkspaceDisplayPrefix==='function'){
return _stripWorkspaceDisplayPrefix(value);
}

View file

@ -1,9 +1,12 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MESSAGES_SRC = (ROOT / "static" / "messages.js").read_text()
SESSIONS_SRC = (ROOT / "static" / "sessions.js").read_text()
UI_SRC = (ROOT / "static" / "ui.js").read_text()
def _function_body(src: str, signature: str) -> str:
@ -21,6 +24,53 @@ def _function_body(src: str, signature: str) -> str:
raise AssertionError(f"could not extract function body for {signature!r}")
def _run_session_identity_probe() -> dict:
prompt = "same submitted prompt\nwith a second line"
workspace_prompt = f"[Workspace::v1: /tmp/hermes-webui]\n{prompt}"
legacy_workspace_prompt = f"[Workspace: /tmp/hermes-webui]\n{prompt}"
attached_prompt = f"{prompt}\n\n[Attached files: /tmp/a.txt]"
forced_prompt = (
"[USER OVERRIDE] You MUST follow the skill 'hermes-webui-coordinator' "
"content provided below before responding to the next message.\n\n"
"[FORCED SKILL CONTEXT: hermes-webui-coordinator]\n"
"skill body that should not make a second user bubble\n"
"[/FORCED SKILL CONTEXT]\n\n"
f"{prompt}"
)
helpers = "\n".join(
[
_function_body(UI_SRC, "function _stripWorkspaceDisplayPrefix"),
_function_body(SESSIONS_SRC, "function _messageComparableText"),
_function_body(SESSIONS_SRC, "function _stripAttachedFilesMarker"),
_function_body(SESSIONS_SRC, "function _stripForcedSkillEnvelope"),
_function_body(SESSIONS_SRC, "function _normalizeUserTranscriptText"),
_function_body(SESSIONS_SRC, "function _sameTranscriptMessage"),
_function_body(SESSIONS_SRC, "function _inflightHasVisibleLiveState"),
]
)
script = f"""
{helpers}
const plain = {{role:'user', content:{json.dumps(prompt)}}};
const workspace = {{role:'user', content:{json.dumps(workspace_prompt)}}};
const legacyWorkspace = {{role:'user', content:{json.dumps(legacy_workspace_prompt)}}};
const attached = {{role:'user', content:{json.dumps(attached_prompt)}}};
const forced = {{role:'user', content:{json.dumps(forced_prompt)}}};
const different = {{role:'user', content:'a different submitted prompt'}};
process.stdout.write(JSON.stringify({{
workspaceDedupe: _sameTranscriptMessage(plain, workspace),
legacyWorkspaceDedupe: _sameTranscriptMessage(plain, legacyWorkspace),
attachedDedupe: _sameTranscriptMessage(plain, attached),
forcedSkillDedupe: _sameTranscriptMessage(plain, forced),
differentUserNotDedupe: !_sameTranscriptMessage(plain, different),
roleMismatchNotDedupe: !_sameTranscriptMessage(plain, {{role:'assistant', content:{json.dumps(prompt)}}}),
userOnlyInflightVisible: _inflightHasVisibleLiveState({{messages:[plain]}}),
emptyUserOnlyInflightNotVisible: !_inflightHasVisibleLiveState({{messages:[{{role:'user', content:' '}}]}}),
}}));
"""
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(proc.stdout)
def test_reattach_path_uses_replay_when_status_reports_journal():
reattach_pos = MESSAGES_SRC.index("let replayOnly=false;")
block = MESSAGES_SRC[reattach_pos : reattach_pos + 1200]
@ -150,31 +200,26 @@ def test_active_reload_keeps_user_only_inflight_visible_until_pending_dedupe():
The browser must not discard the user-only optimistic INFLIGHT entry as a
cursor-only snapshot before pending/live replay reconciliation runs.
"""
helper = _function_body(SESSIONS_SRC, "function _inflightHasVisibleLiveState")
result = _run_session_identity_probe()
assert "msg.role === 'user'" in helper or "msg.role==='user'" in helper
assert "_messageComparableText(msg)" in helper or "msg.content" in helper
assert result["userOnlyInflightVisible"] is True
assert result["emptyUserOnlyInflightNotVisible"] is True
def test_pending_user_merge_dedupes_workspace_prefixed_replay_rows():
def test_pending_user_merge_dedupes_user_turn_variants_by_behavior():
"""Pending user rows and replayed/checkpointed user rows share one turn.
The model-facing current user message may carry the WebUI workspace sentinel,
while pending_user_message stores only the human prompt. Frontend transcript
equality must normalize the same way the renderer and backend context
identity do, otherwise active reload/reconnect can show two user bubbles for
one submitted turn.
Execute the same JavaScript helpers the browser uses so the regression test
catches regex/order/trim mistakes, not just identifier wiring.
"""
same = _function_body(SESSIONS_SRC, "function _sameTranscriptMessage")
normalizer = _function_body(SESSIONS_SRC, "function _normalizeUserTranscriptText")
forced = _function_body(SESSIONS_SRC, "function _stripForcedSkillEnvelope")
result = _run_session_identity_probe()
assert "_normalizeUserTranscriptText" in same
assert "_stripWorkspaceDisplayPrefix" in normalizer
assert "_stripAttachedFilesMarker" in normalizer
assert "Workspace::v1" in normalizer
assert "FORCED SKILL CONTEXT" in forced
assert "USER OVERRIDE" in forced
assert result["workspaceDedupe"] is True
assert result["legacyWorkspaceDedupe"] is True
assert result["attachedDedupe"] is True
assert result["forcedSkillDedupe"] is True
assert result["differentUserNotDedupe"] is True
assert result["roleMismatchNotDedupe"] is True
def test_live_tool_matching_uses_the_same_aliases_as_live_card_dedup():

View file

@ -119,32 +119,34 @@ def test_eager_checkpointed_user_is_removed_from_model_context():
assert [m["content"] for m in context] == ["older", "prior"]
def test_submitted_turn_journal_does_not_duplicate_current_model_context(tmp_path):
from api.turn_journal import append_turn_journal_event
def test_active_pending_current_user_is_removed_from_model_context():
"""Current pending user text must not appear in conversation_history twice.
The provider receives the active turn as `user_message`; if an eager
checkpoint/recovery path has already put the same current user text at the
end of `context_messages`, `_context_messages_for_new_turn` must strip it
before building the model-facing history. Turn-journal `submitted` records
are intentionally not a context source for this helper.
"""
session = Session(
session_id="journal_context_current_turn",
title="journal context",
messages=[{"role": "user", "content": "older"}],
context_messages=[{"role": "user", "content": "older"}],
messages=[
{"role": "user", "content": "older"},
{"role": "assistant", "content": "prior"},
],
context_messages=[
{"role": "user", "content": "older"},
{"role": "assistant", "content": "prior"},
{"role": "user", "content": "[Workspace::v1: /tmp/hermes]\ncurrent prompt"},
],
active_stream_id="stream-current",
pending_user_message="current prompt",
)
append_turn_journal_event(
session.session_id,
{
"event": "submitted",
"stream_id": "stream-current",
"role": "user",
"content": "current prompt",
"created_at": 123.0,
},
session_dir=tmp_path,
)
context = streaming._context_messages_for_new_turn(session, "current prompt")
assert [m["content"] for m in context] == ["older"]
assert [m["content"] for m in context] == ["older", "prior"]
def test_eager_checkpointed_user_is_not_duplicated_after_agent_result():