From 1286ea7b3db7218545b33791bb88eb45da685f4c Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 15 Jun 2026 20:02:57 -0700 Subject: [PATCH] Release PJ (v0.51.449): stop recovered turns replaying into later context (#4283) (#4291) * 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 --------- Co-authored-by: nesquena-hermes Co-authored-by: rodboev --- CHANGELOG.md | 6 +++ api/models.py | 13 ++++- ...t_webui_state_db_context_reconciliation.py | 52 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fecd98f61..033e0822e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/models.py b/api/models.py index 439a07ab1..f5b409ce3 100644 --- a/api/models.py +++ b/api/models.py @@ -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 diff --git a/tests/test_webui_state_db_context_reconciliation.py b/tests/test_webui_state_db_context_reconciliation.py index f2b16cddf..00958ac0b 100644 --- a/tests/test_webui_state_db_context_reconciliation.py +++ b/tests/test_webui_state_db_context_reconciliation.py @@ -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