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.
620 lines
30 KiB
Python
620 lines
30 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MESSAGES_SRC = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
|
SESSIONS_SRC = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
UI_SRC = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _function_body(src: str, signature: str) -> str:
|
|
start = src.index(signature)
|
|
brace = src.index("{", start)
|
|
depth = 0
|
|
for idx in range(brace, len(src)):
|
|
char = src[idx]
|
|
if char == "{":
|
|
depth += 1
|
|
elif char == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[start : idx + 1]
|
|
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}"
|
|
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 _currentTailUserMessage"),
|
|
_function_body(SESSIONS_SRC, "function _hasCurrentTailUserDuplicate"),
|
|
_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 _run_current_turn_scope_probe() -> dict:
|
|
prompt = "repeat me"
|
|
historical_workspace_prompt = f"[Workspace::v1: /tmp/old]\n{prompt}"
|
|
current_workspace_prompt = f"[Workspace::v1: /tmp/current]\n{prompt}"
|
|
helpers = "\n".join(
|
|
[
|
|
_function_body(UI_SRC, "function _stripWorkspaceDisplayPrefix"),
|
|
_function_body(UI_SRC, "function msgContent"),
|
|
_function_body(UI_SRC, "function _isContextCompactionText"),
|
|
_function_body(UI_SRC, "function _isContextCompactionMessage"),
|
|
_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 _currentTailUserMessage"),
|
|
_function_body(SESSIONS_SRC, "function _hasCurrentTailUserDuplicate"),
|
|
_function_body(SESSIONS_SRC, "function _mergePendingSessionMessage"),
|
|
_function_body(SESSIONS_SRC, "function _mergeInflightTailMessages"),
|
|
]
|
|
)
|
|
script = f"""
|
|
{helpers}
|
|
function getPendingSessionMessage(session, messages){{
|
|
const text=String(session&&session.pending_user_message||'').trim();
|
|
if(!text) return null;
|
|
return {{
|
|
role:'user',
|
|
content:text,
|
|
_ts:session.pending_started_at||10,
|
|
_pending:true,
|
|
}};
|
|
}}
|
|
const historical = {{role:'user', content:{json.dumps(historical_workspace_prompt)}, _ts:1}};
|
|
const historicalAnswer = {{role:'assistant', content:'done', _ts:2}};
|
|
const liveAssistant = {{role:'assistant', content:'working', _live:true, _ts:4}};
|
|
const pendingSession = {{pending_user_message:{json.dumps(prompt)}, pending_started_at:3}};
|
|
|
|
const pendingAfterHistory = [historical, historicalAnswer];
|
|
const insertedAfterHistory = _mergePendingSessionMessage(pendingSession, pendingAfterHistory);
|
|
|
|
const pendingBeforeLive = [historical, historicalAnswer, liveAssistant];
|
|
const insertedBeforeLive = _mergePendingSessionMessage(pendingSession, pendingBeforeLive);
|
|
|
|
const optimisticCurrent = {{role:'user', content:{json.dumps(current_workspace_prompt)}, _ts:3}};
|
|
const pendingWithCurrent = [historical, historicalAnswer, optimisticCurrent, liveAssistant];
|
|
const insertedWithCurrent = _mergePendingSessionMessage(pendingSession, pendingWithCurrent);
|
|
|
|
const inflightAfterHistory = _mergeInflightTailMessages(
|
|
[historical, historicalAnswer],
|
|
[{{role:'user', content:{json.dumps(prompt)}, _ts:3}}, liveAssistant]
|
|
);
|
|
|
|
const inflightWithCurrent = _mergeInflightTailMessages(
|
|
[historical, historicalAnswer, optimisticCurrent],
|
|
[{{role:'user', content:{json.dumps(prompt)}, _ts:3}}, liveAssistant]
|
|
);
|
|
|
|
const compaction = {{
|
|
role:'user',
|
|
content:'[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.',
|
|
_ts:3.5,
|
|
}};
|
|
const compactionBase = [historical, historicalAnswer, optimisticCurrent, compaction];
|
|
const compactionCandidate = {{role:'user', content:{json.dumps(prompt)}, _ts:3}};
|
|
const compactionCurrentTail = _currentTailUserMessage(compactionBase);
|
|
const compactionTailDuplicate = _hasCurrentTailUserDuplicate(compactionBase, compactionCandidate);
|
|
const compactionMerged = _mergeInflightTailMessages(
|
|
compactionBase,
|
|
[compactionCandidate, liveAssistant]
|
|
);
|
|
const insertedAfterCompaction = _mergePendingSessionMessage(pendingSession, compactionMerged);
|
|
const compactionPromptCount = compactionMerged.filter(
|
|
m=>m&&m.role==='user'&&m._ts===3&&_normalizeUserTranscriptText(m.content)==={json.dumps(prompt)}
|
|
).length;
|
|
const compactionMarkerRetained = compactionMerged.some(m=>_isContextCompactionMessage(m));
|
|
const compactionLiveAssistantRetained = compactionMerged.some(
|
|
m=>m&&m.role==='assistant'&&m._live&&m.content==='working'
|
|
);
|
|
const completedBoundaryDedupe = _hasCurrentTailUserDuplicate(
|
|
[historical, historicalAnswer, compaction],
|
|
{{role:'user', content:{json.dumps(prompt)}, _ts:3}}
|
|
);
|
|
const distinctCompletedTurnPromptCount = inflightAfterHistory.filter(
|
|
m=>m&&m.role==='user'&&_normalizeUserTranscriptText(m.content)==={json.dumps(prompt)}
|
|
).length;
|
|
|
|
process.stdout.write(JSON.stringify({{
|
|
insertedAfterHistory,
|
|
pendingAfterHistoryRoles: pendingAfterHistory.map(m=>m.role),
|
|
insertedBeforeLive,
|
|
pendingBeforeLiveRoles: pendingBeforeLive.map(m=>m.role),
|
|
insertedWithCurrent,
|
|
pendingWithCurrentRoles: pendingWithCurrent.map(m=>m.role),
|
|
inflightAfterHistoryRoles: inflightAfterHistory.map(m=>m.role),
|
|
inflightWithCurrentRoles: inflightWithCurrent.map(m=>m.role),
|
|
insertedAfterCompaction,
|
|
compactionCurrentTailContent: compactionCurrentTail&&compactionCurrentTail.content,
|
|
compactionTailDuplicate,
|
|
compactionPromptCount,
|
|
compactionMarkerRetained,
|
|
compactionLiveAssistantRetained,
|
|
completedBoundaryDedupe,
|
|
distinctCompletedTurnPromptCount,
|
|
}}));
|
|
"""
|
|
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
def _run_pending_session_message_probe() -> dict:
|
|
prompt = "repeat me"
|
|
historical_workspace_prompt = f"[Workspace::v1: /tmp/old]\n{prompt}"
|
|
current_workspace_prompt = f"[Workspace::v1: /tmp/current]\n{prompt}"
|
|
helpers = "\n".join(
|
|
[
|
|
_function_body(UI_SRC, "function _stripWorkspaceDisplayPrefix"),
|
|
_function_body(UI_SRC, "function msgContent"),
|
|
_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(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"),
|
|
_function_body(UI_SRC, "function getPendingSessionMessage"),
|
|
]
|
|
)
|
|
script = f"""
|
|
const _PENDING_ACTIVE_TURN_TS_EPSILON=1e-6;
|
|
{helpers}
|
|
const prompt = {json.dumps(prompt)};
|
|
const historical = {{role:'user', content:prompt, _ts:1}};
|
|
const historicalWorkspace = {{role:'user', content:{json.dumps(historical_workspace_prompt)}, _ts:1}};
|
|
const historicalAnswer = {{role:'assistant', content:'done', _ts:2}};
|
|
const currentTail = {{role:'user', content:prompt, _ts:3}};
|
|
const currentWorkspaceTail = {{role:'user', content:{json.dumps(current_workspace_prompt)}, _ts:3}};
|
|
const currentTailForCompaction = {{role:'user', content:prompt, _ts:3}};
|
|
const liveAssistant = {{role:'assistant', content:'working', _live:true, _ts:4}};
|
|
const attachments = [{{name:'note.txt', path:'note.txt', mime:'text/plain'}}];
|
|
const compactionMarker = {{
|
|
role:'user',
|
|
content:'[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.',
|
|
_ts:3.5,
|
|
}};
|
|
const repeatedPromptTurnOne = {{role:'user', content:prompt, _ts:1}};
|
|
const repeatedPromptAnswerOne = {{role:'assistant', content:'done', _ts:2}};
|
|
const repeatedPromptTurnTwo = {{role:'user', content:prompt, _ts:3}};
|
|
const repeatedPromptAnswerTwo = {{role:'assistant', content:'done', _ts:4}};
|
|
const repeatedCompletedBase = [
|
|
repeatedPromptTurnOne,
|
|
repeatedPromptAnswerOne,
|
|
repeatedPromptTurnTwo,
|
|
repeatedPromptAnswerTwo,
|
|
];
|
|
|
|
const fromHistoricalSameText = getPendingSessionMessage(
|
|
{{pending_user_message:prompt, pending_started_at:3}},
|
|
[historical, historicalAnswer]
|
|
);
|
|
const fromHistoricalWorkspace = getPendingSessionMessage(
|
|
{{pending_user_message:prompt, pending_started_at:3}},
|
|
[historicalWorkspace, historicalAnswer]
|
|
);
|
|
const exactCurrentMessages = [historical, historicalAnswer, currentTail];
|
|
const exactCurrentResult = getPendingSessionMessage(
|
|
{{pending_user_message:prompt, pending_started_at:4, pending_attachments:attachments}},
|
|
exactCurrentMessages
|
|
);
|
|
const workspaceCurrentResult = getPendingSessionMessage(
|
|
{{pending_user_message:prompt, pending_started_at:4}},
|
|
[historical, historicalAnswer, currentWorkspaceTail]
|
|
);
|
|
const liveAfterCurrentResult = getPendingSessionMessage(
|
|
{{pending_user_message:prompt, pending_started_at:4}},
|
|
[historical, historicalAnswer, currentWorkspaceTail, liveAssistant]
|
|
);
|
|
const differentTailResult = getPendingSessionMessage(
|
|
{{pending_user_message:prompt, pending_started_at:4}},
|
|
[historical, historicalAnswer, {{role:'user', content:'different prompt', _ts:3}}]
|
|
);
|
|
const compactionTailResult = getPendingSessionMessage(
|
|
{{pending_user_message:prompt, pending_started_at:4, pending_attachments:attachments}},
|
|
[historical, historicalAnswer, currentTailForCompaction, compactionMarker]
|
|
);
|
|
const repeatedCompletedResult = getPendingSessionMessage(
|
|
{{pending_user_message:prompt, pending_started_at:5}},
|
|
repeatedCompletedBase
|
|
);
|
|
const repeatedCompletedMessages = repeatedCompletedResult
|
|
? [...repeatedCompletedBase, repeatedCompletedResult]
|
|
: repeatedCompletedBase;
|
|
const repeatedCompletedPromptCount = repeatedCompletedMessages.filter(
|
|
m=>m&&m.role==='user'&&_normalizeUserTranscriptText(m.content)===prompt
|
|
).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
|
|
);
|
|
|
|
// 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,
|
|
exactCurrentTailDedupe: exactCurrentResult===null,
|
|
exactCurrentTailAttachmentsCopied: Array.isArray(currentTail.attachments) && currentTail.attachments[0].name==='note.txt',
|
|
workspaceCurrentTailDedupe: workspaceCurrentResult===null,
|
|
liveAfterCurrentTailDedupe: liveAfterCurrentResult===null,
|
|
differentCurrentTailSurvives: !!differentTailResult && differentTailResult.content===prompt && differentTailResult._pending===true,
|
|
compactionBoundaryDedupe: compactionTailResult===null,
|
|
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,
|
|
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),
|
|
}}));
|
|
"""
|
|
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;")
|
|
# Window widened to 2200: the SSE-recovery follow-restore fix (the
|
|
# _wasFollowingAtReconnectDead guard + its sticky-unpin check) inserted lines
|
|
# into the reconnect-dead cleanup block between this anchor and the
|
|
# replay-params assertion below, pushing the target string past the old slice.
|
|
block = MESSAGES_SRC[reattach_pos : reattach_pos + 2200]
|
|
|
|
assert "st.replay_available" in block
|
|
assert "replayOnly=true" in block
|
|
assert "(reconnecting||replayOnly)?_runJournalReplayParams():''" in block
|
|
assert "_clearOwnerInflightState()" in block
|
|
|
|
|
|
def test_error_reconnect_path_can_restore_from_journal():
|
|
# Anchor on the reconnect block's stable entry point rather than the exact
|
|
# composer-status string: the first status was changed to a template literal
|
|
# `Reconnecting… (1/${_retryDelays.length})` (staged-probe counter), so the
|
|
# old single-quoted "setComposerStatus('Reconnecting" anchor no longer exists.
|
|
reconnect_pos = MESSAGES_SRC.index("_reconnectAttempted=true;")
|
|
block = MESSAGES_SRC[reconnect_pos : reconnect_pos + 1100]
|
|
|
|
assert "st.active" in block
|
|
assert "st.replay_available" in block
|
|
assert "Restoring stream" in block
|
|
assert "_runJournalReplayParams()" in block
|
|
|
|
|
|
def test_frontend_replay_cursor_uses_eventsource_last_event_id():
|
|
cursor_pos = MESSAGES_SRC.index("function _rememberRunJournalCursor")
|
|
block = MESSAGES_SRC[cursor_pos : cursor_pos + 1000]
|
|
|
|
assert "e.lastEventId" in block
|
|
assert "lastIndexOf(':')" in block
|
|
assert "_lastRunJournalSeq=seq" in block
|
|
assert "source.addEventListener(_runJournalEventName,_rememberRunJournalCursor)" in MESSAGES_SRC
|
|
assert "after_seq=${encodeURIComponent(String(_runJournalReplayAfterSeq()))}" in MESSAGES_SRC
|
|
assert "after_seq=0" not in MESSAGES_SRC
|
|
|
|
|
|
def test_replayed_long_task_events_enter_the_same_live_timeline_handlers():
|
|
"""Run-journal replay must not grow a parallel long-task renderer.
|
|
|
|
The run-state consistency contract depends on replayed journal events
|
|
flowing through the same EventSource handlers as live streams. Otherwise a
|
|
live long task can render as Thinking -> progress text -> tool cards, while
|
|
the same journaled event sequence replays as a flattened or reordered scene.
|
|
"""
|
|
wire_pos = MESSAGES_SRC.index("function _wireSSE(source)")
|
|
wire_block = MESSAGES_SRC[wire_pos : MESSAGES_SRC.index("async function _restoreSettledSession", wire_pos)]
|
|
replay_events = [
|
|
"reasoning",
|
|
"interim_assistant",
|
|
"tool",
|
|
"tool_complete",
|
|
"compressing",
|
|
"compressed",
|
|
"metering",
|
|
"done",
|
|
"apperror",
|
|
]
|
|
|
|
for event_name in replay_events:
|
|
assert f"source.addEventListener('{event_name}'" in wire_block, (
|
|
f"{event_name} must be handled by the shared live/replay SSE pipeline"
|
|
)
|
|
|
|
thinking_helper = MESSAGES_SRC[
|
|
MESSAGES_SRC.index("function _updateLiveThinkingCard") :
|
|
MESSAGES_SRC.index("// Split a content string", MESSAGES_SRC.index("function _updateLiveThinkingCard"))
|
|
]
|
|
assert "_updateLiveThinkingCard(" in wire_block, "reasoning replay should use the live Thinking card path"
|
|
assert "updateThinking(text, opts)" in thinking_helper and "appendThinking(text, opts)" in thinking_helper, (
|
|
"the shared Thinking helper should still route replay/live reasoning into the Worklog Thinking card path"
|
|
)
|
|
assert "appendLiveToolCard(tc" in wire_block, "tool replay should use live tool-card rendering"
|
|
# Compression replay must dispatch through setCompressionUi(...). The handler
|
|
# body may build the state object inline (`setCompressionUi({...})`) or hoist
|
|
# it into a `state` variable first (`setCompressionUi(state)`) — both forms
|
|
# use the same compression-card path, so accept either. Pinning the literal
|
|
# `{` after the open-paren was over-specific and broke in v0.51.76 when
|
|
# PR #2347 hoisted the state object to share it with `appendLiveCompressionCard`.
|
|
assert ("setCompressionUi({" in wire_block) or ("setCompressionUi(state)" in wire_block), (
|
|
"compression replay should use the compression card path"
|
|
)
|
|
assert "_runJournalReplayParams()" in MESSAGES_SRC, "replay attachments should enter _wireSSE via EventSource"
|
|
|
|
|
|
def test_run_journal_cursor_tracks_every_long_task_timeline_event():
|
|
"""Every user-visible long-task event needs cursor tracking for parity replay."""
|
|
cursor_loop_pos = MESSAGES_SRC.index("for(const _runJournalEventName of [")
|
|
cursor_loop = MESSAGES_SRC[cursor_loop_pos : MESSAGES_SRC.index("]", cursor_loop_pos)]
|
|
timeline_events = [
|
|
"token",
|
|
"interim_assistant",
|
|
"reasoning",
|
|
"tool",
|
|
"tool_complete",
|
|
"compressing",
|
|
"compressed",
|
|
"metering",
|
|
"done",
|
|
"apperror",
|
|
"cancel",
|
|
]
|
|
|
|
for event_name in timeline_events:
|
|
assert f"'{event_name}'" in cursor_loop, (
|
|
f"{event_name} must advance the replay cursor to avoid duplicate timeline replay"
|
|
)
|
|
|
|
|
|
def test_server_runtime_journal_snapshot_restores_structured_inflight_state():
|
|
helper_pos = SESSIONS_SRC.index("function _serverLiveSnapshotToolId")
|
|
helper_block = SESSIONS_SRC[helper_pos : helper_pos + 3600]
|
|
load_pos = SESSIONS_SRC.index("async function loadSession")
|
|
load_end = SESSIONS_SRC.index("// ── Handoff hint logic", load_pos)
|
|
load_block = SESSIONS_SRC[load_pos:load_end]
|
|
|
|
assert "runtime_journal_snapshot" in load_block
|
|
assert "_serverLiveSnapshotInflight(S.session.runtime_journal_snapshot" in load_block
|
|
assert "!_inflightHasVisibleLiveState(INFLIGHT[sid])" in load_block
|
|
assert "journalSnapshot:true" in helper_block
|
|
assert "lastRunJournalSeq" in helper_block
|
|
assert "last_assistant_text" in helper_block
|
|
assert "activity_burst_anchors" in helper_block
|
|
for key in ("tid", "id", "tool_call_id", "tool_use_id", "call_id"):
|
|
assert key in helper_block
|
|
|
|
|
|
def test_active_reload_keeps_user_only_inflight_visible_until_pending_dedupe():
|
|
"""A just-submitted user row is visible live state before first assistant text.
|
|
|
|
On an active first-turn reload, the sidecar can still have messages=[] while
|
|
pending_user_message and the submitted turn journal record the same prompt.
|
|
The browser must not discard the user-only optimistic INFLIGHT entry as a
|
|
cursor-only snapshot before pending/live replay reconciliation runs.
|
|
"""
|
|
result = _run_session_identity_probe()
|
|
|
|
assert result["userOnlyInflightVisible"] is True
|
|
assert result["emptyUserOnlyInflightNotVisible"] is True
|
|
|
|
|
|
def test_pending_user_merge_dedupes_user_turn_variants_by_behavior():
|
|
"""Pending user rows and replayed/checkpointed user rows share one turn.
|
|
|
|
Execute the same JavaScript helpers the browser uses so the regression test
|
|
catches regex/order/trim mistakes, not just identifier wiring.
|
|
"""
|
|
result = _run_session_identity_probe()
|
|
|
|
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_user_turn_dedupe_is_scoped_to_current_turn_by_behavior():
|
|
result = _run_current_turn_scope_probe()
|
|
|
|
assert result["insertedAfterHistory"] is True
|
|
assert result["pendingAfterHistoryRoles"] == ["user", "assistant", "user"]
|
|
assert result["insertedBeforeLive"] is True
|
|
assert result["pendingBeforeLiveRoles"] == ["user", "assistant", "user", "assistant"]
|
|
|
|
assert result["insertedWithCurrent"] is False
|
|
assert result["pendingWithCurrentRoles"] == ["user", "assistant", "user", "assistant"]
|
|
|
|
assert result["inflightAfterHistoryRoles"] == ["user", "assistant", "user", "assistant"]
|
|
assert result["inflightWithCurrentRoles"] == ["user", "assistant", "user", "assistant"]
|
|
|
|
assert result["insertedAfterCompaction"] is False
|
|
assert result["compactionCurrentTailContent"] == "[Workspace::v1: /tmp/current]\nrepeat me"
|
|
assert result["compactionTailDuplicate"] is True
|
|
assert result["compactionPromptCount"] == 1
|
|
assert result["compactionMarkerRetained"] is True
|
|
assert result["compactionLiveAssistantRetained"] is True
|
|
assert result["completedBoundaryDedupe"] is False
|
|
assert result["distinctCompletedTurnPromptCount"] == 2
|
|
|
|
|
|
def test_get_pending_session_message_keeps_deferred_repeat_prompt_by_behavior():
|
|
"""Deferred active reload must not hide a current repeat prompt.
|
|
|
|
In the default deferred save mode, chat start persists only
|
|
pending_user_message before the worker appends the display row. If an older
|
|
turn has the same visible user text, getPendingSessionMessage still has to
|
|
return the current pending row; downstream merge code then dedupes only
|
|
against the current tail.
|
|
"""
|
|
result = _run_pending_session_message_probe()
|
|
|
|
assert result["historicalSameTextSurvives"] is True
|
|
assert result["historicalWorkspaceSurvives"] is True
|
|
assert result["exactCurrentTailDedupe"] is True
|
|
assert result["exactCurrentTailAttachmentsCopied"] is True
|
|
assert result["workspaceCurrentTailDedupe"] is True
|
|
assert result["liveAfterCurrentTailDedupe"] is True
|
|
assert result["differentCurrentTailSurvives"] is True
|
|
assert result["compactionBoundaryDedupe"] is True
|
|
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
|
|
# #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
|
|
|
|
|
|
def test_live_tool_matching_uses_the_same_aliases_as_live_card_dedup():
|
|
live_tid_pos = MESSAGES_SRC.index("function _liveToolTid")
|
|
live_tid_block = MESSAGES_SRC[live_tid_pos : live_tid_pos + 450]
|
|
find_pos = MESSAGES_SRC.index("function _findPendingLiveToolCallIndex")
|
|
find_block = MESSAGES_SRC[find_pos : find_pos + 900]
|
|
upsert_pos = MESSAGES_SRC.index("function upsertLiveToolCall")
|
|
upsert_block = MESSAGES_SRC[upsert_pos : upsert_pos + 600]
|
|
|
|
for key in ("tid", "id", "tool_call_id", "tool_use_id", "call_id"):
|
|
assert key in live_tid_block
|
|
assert key in find_block
|
|
assert key in upsert_block
|