mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
* stage-4286: stop recovered turns replaying into later context (fixes #4283) * Release PJ: stop recovered turns replaying into later context (#4283) rodboev's #4286. Codex SAFE + Opus net-positive-and-safe + 9152/0 clean suite. Single-row prefix allowance gated to _recovered+role==user+offset0. Co-authored-by: rodboev <rodboev@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,12 @@
|
||||
|
||||
- **Windows pytest-harness compatibility (#3664).** Hardened the test suite to run on Windows: profile-home fallback paths are path-normalized, strict POSIX file-mode (`0o600`) assertions are gated behind `os.name != "nt"` (Linux still asserts them at full strictness), the conftest cleanup handles Windows process-tree/port teardown and the Py3.12+ `shutil.rmtree` `onexc` shim, and tests that require `fork`/`fcntl` carry `@requires_fork` / `@requires_fcntl` markers (which never skip on Linux). Test-only — no runtime or app behavior change, no Linux CI behavior change. (#4254, #4255, #4256, #4257, #4259, #4263, #4266, #4274)
|
||||
|
||||
## [v0.51.449] — 2026-06-16 — Release PJ (stop recovered turns replaying into later context)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Interrupted conversations no longer replay the prior message into every later turn.** After a session was interrupted (gateway restart or tool-iteration limit) and recovered, the WebUI could re-inject the recovered user message into the context of each subsequent turn. The state-database context reconciliation now recognizes a recovered turn as a valid single-row aligned prefix (gated strictly to a recovered user turn at the head of the window), so it's matched and de-duplicated instead of replayed. (#4283)
|
||||
|
||||
## [v0.51.448] — 2026-06-16 — Release PI (fix blank transcript viewport regression)
|
||||
|
||||
### Fixed
|
||||
|
||||
+12
-1
@@ -4446,10 +4446,20 @@ def state_db_delta_after_context(sidecar_context: list, state_messages: list) ->
|
||||
if not sidecar_context or not state_messages:
|
||||
return state_messages
|
||||
|
||||
# Recovered interrupted turns are special: the visible interruption marker
|
||||
# is synthetic, so the recovered user turn should still count as a mirrored
|
||||
# prefix when it is the actual aligned prefix row.
|
||||
allow_single_row_prefix = bool(
|
||||
isinstance(sidecar_context[0], dict)
|
||||
and sidecar_context[0].get('_recovered')
|
||||
and str(sidecar_context[0].get('role') or '') == 'user'
|
||||
)
|
||||
|
||||
sidecar_keys = [_session_message_content_key(m) for m in sidecar_context]
|
||||
state_keys = [_session_message_content_key(m) for m in state_messages]
|
||||
max_offset = min(len(sidecar_keys), len(state_keys))
|
||||
best_len = 0
|
||||
best_offset = 0
|
||||
for offset in range(max_offset):
|
||||
length = 0
|
||||
while (
|
||||
@@ -4460,12 +4470,13 @@ def state_db_delta_after_context(sidecar_context: list, state_messages: list) ->
|
||||
length += 1
|
||||
if length > best_len:
|
||||
best_len = length
|
||||
best_offset = offset
|
||||
|
||||
# Require at least two mirrored rows. A single repeated short user message
|
||||
# is not enough evidence that state.db starts with a mirrored context
|
||||
# segment, but small recovered contexts often contain only a compact summary
|
||||
# and one follow-up row; those should still use the delta path.
|
||||
if best_len < 2:
|
||||
if best_len < (1 if allow_single_row_prefix and best_offset == 0 else 2):
|
||||
return state_messages
|
||||
|
||||
# Drop only rows that can be aligned with the remaining sidecar context in
|
||||
|
||||
@@ -132,6 +132,58 @@ def test_next_webui_turn_context_includes_state_db_external_messages(monkeypatch
|
||||
]
|
||||
|
||||
|
||||
def test_state_db_delta_after_context_allows_recovered_turn_prefix():
|
||||
from api.models import state_db_delta_after_context
|
||||
|
||||
sidecar_context = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "alright gateway restarted, lets give it a live test...",
|
||||
"_recovered": True,
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"**Response interrupted.**\n\n"
|
||||
"The live response stream stopped before this turn finished. "
|
||||
"The user message above was preserved, but no agent output was recovered."
|
||||
),
|
||||
"_error": True,
|
||||
"type": "interrupted",
|
||||
},
|
||||
]
|
||||
state_messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "alright gateway restarted, lets give it a live test...",
|
||||
"timestamp": 1.0,
|
||||
},
|
||||
{"role": "assistant", "content": "old assistant", "timestamp": 2.0},
|
||||
{"role": "user", "content": "new prompt", "timestamp": 3.0},
|
||||
]
|
||||
|
||||
delta = state_db_delta_after_context(sidecar_context, state_messages)
|
||||
|
||||
assert [m.get("content") for m in delta] == ["old assistant", "new prompt"]
|
||||
|
||||
|
||||
def test_state_db_delta_after_context_does_not_promote_unrelated_prefix_as_recovered():
|
||||
from api.models import state_db_delta_after_context
|
||||
|
||||
sidecar_context = [
|
||||
{"role": "user", "content": "hi", "_recovered": True},
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
state_messages = [
|
||||
{"role": "user", "content": "hello", "timestamp": 1.0},
|
||||
{"role": "assistant", "content": "response", "timestamp": 2.0},
|
||||
]
|
||||
|
||||
delta = state_db_delta_after_context(sidecar_context, state_messages)
|
||||
|
||||
assert [m.get("content") for m in delta] == ["hello", "response"]
|
||||
|
||||
|
||||
def test_webui_streaming_normalizes_trailing_prefill_user_before_current_turn(monkeypatch, tmp_path):
|
||||
import api.config as config
|
||||
import api.models as models
|
||||
|
||||
Reference in New Issue
Block a user