From 327f7efab8b28a77d40d24d577cacfffa6f0e598 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:53:08 +0530 Subject: [PATCH] fix: close sibling display_kind drops and ui-tui parity for #82756 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the composite salvage (whole-bug-class sweep): - session.branch and _persist_branch_seed copied parent history without display_kind/display_metadata, so a tagged timeline marker (personality pivot, model switch, auto-continue) re-entered the branched session as a bare role=user row after a restart — re-planting the phantom-ordinal class this PR fixes. Both projection dicts now carry the tags; regression asserts added to both branch tests (mutation-checked: fail without the fix). - ui-tui renderer learns display_kind=personality_switch (was falling through to an opaque user bubble; desktop got the case in commit 1). - programmatic-integration docs: document the two new 4004 refusals (boolean ordinal, bare confirm_truncate). - hermes_state comment: archived rows are searchable only with include_inactive=True, not by default search — align comment with the actual FTS filter. - strip stray trailing blank line in test_tui_gateway_server.py --- hermes_state.py | 7 +++-- tests/test_tui_gateway_server.py | 30 ++++++++++++++++++- tui_gateway/methods_session.py | 7 +++++ tui_gateway/server.py | 7 +++++ ui-tui/src/domain/messages.ts | 7 +++++ .../programmatic-integration.md | 4 +-- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index d60d39afd57f3..27aaee7a4b317 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -8026,9 +8026,10 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) raise CompressionSessionClosedError(session_id) if archive_dropped: # Content-preserving UPDATE: the rows keep their FTS entries - # (the messages_fts triggers index on INSERT / drop on DELETE - # and don't key on active), so the replaced turns stay both - # readable and searchable-by-id after the rewrite. + # (the messages_fts triggers fire on INSERT / DELETE / UPDATE + # of content columns, not on `active`), so the replaced turns + # stay readable via get_messages(include_inactive=True) and + # searchable with include_inactive=True after the rewrite. conn.execute( "UPDATE messages SET active = 0 " "WHERE session_id = ? AND active = 1", diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index d614cb39c2c2a..572c2c8710e47 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -16611,9 +16611,28 @@ def _branch_history(): "codex_reasoning_items": BRANCH_CODEX_REASONING_ITEMS, "codex_message_items": BRANCH_CODEX_MESSAGE_ITEMS, }, + # Timeline marker: rides as role=user but must keep its tag through + # the branch copy, or it re-enters the truncate ordinal address space + # as a phantom user turn after a restart (#82756). + { + "role": "user", + "content": "[System: personality changed]", + "display_kind": "personality_switch", + }, ] +def _branched_marker(db, session_key): + return next( + ( + m + for m in db.get_messages_as_conversation(session_key) + if m.get("display_kind") == "personality_switch" + ), + None, + ) + + def _branched_assistant(db, session_key): return next( m @@ -16650,6 +16669,11 @@ def test_persist_branch_seed_keeps_reasoning_fields(monkeypatch, tmp_path): assert assistant["reasoning_details"] == BRANCH_REASONING_DETAILS assert assistant["codex_reasoning_items"] == BRANCH_CODEX_REASONING_ITEMS assert assistant["codex_message_items"] == BRANCH_CODEX_MESSAGE_ITEMS + marker = _branched_marker(db, "branch-key") + assert marker is not None, ( + "the branch seed dropped display_kind: the marker re-entered the " + "truncate ordinal address space as a phantom user turn (#82756)" + ) assert session["_branch_seed_persisted"] is True finally: db.close() @@ -16692,6 +16716,11 @@ def test_session_branch_keeps_reasoning_fields(monkeypatch, tmp_path): assert assistant["reasoning_details"] == BRANCH_REASONING_DETAILS assert assistant["codex_reasoning_items"] == BRANCH_CODEX_REASONING_ITEMS assert assistant["codex_message_items"] == BRANCH_CODEX_MESSAGE_ITEMS + marker = _branched_marker(db, "branch-key") + assert marker is not None, ( + "session.branch dropped display_kind: the marker re-entered the " + "truncate ordinal address space as a phantom user turn (#82756)" + ) finally: server._sessions.pop("sid", None) db.close() @@ -16997,4 +17026,3 @@ def test_prompt_submit_truncation_archives_instead_of_deleting(monkeypatch): assert captured.get("active_only") is True finally: server._sessions.pop("archive-trunc-sid", None) - diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 1e0b41254a868..27adddadfa7c6 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -2825,6 +2825,13 @@ def _(rid, params: dict) -> dict: "reasoning_details": msg.get("reasoning_details"), "codex_reasoning_items": msg.get("codex_reasoning_items"), "codex_message_items": msg.get("codex_message_items"), + # Timeline markers (model_switch, personality_switch, + # auto_continue, …) ride as role=user; dropping the tag + # here re-planted them as bare user turns after a + # restart, corrupting the truncate ordinal address + # space the same way #82756 did. + "display_kind": msg.get("display_kind"), + "display_metadata": msg.get("display_metadata"), # Preserve the parent's original message timestamps — # branch copies are history, not new activity (9d73006ad). "timestamp": msg.get("timestamp"), diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6bbbb260cb0ae..874e8d3188c76 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2894,6 +2894,13 @@ def _persist_branch_seed(session: dict) -> None: "reasoning_details": msg.get("reasoning_details"), "codex_reasoning_items": msg.get("codex_reasoning_items"), "codex_message_items": msg.get("codex_message_items"), + # Timeline markers (model_switch, personality_switch, + # auto_continue, …) ride as role=user; dropping the tag + # here re-planted them as bare user turns after a + # restart, corrupting the truncate ordinal address + # space the same way #82756 did. + "display_kind": msg.get("display_kind"), + "display_metadata": msg.get("display_metadata"), # Preserve the parent's original message timestamps — # append_message would otherwise stamp time.time() and the # branch's copied history would all appear authored "now". diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index b4428520d81d6..5ec93cecce19a 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -61,6 +61,13 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { continue } + if (display_kind === 'personality_switch') { + out.push({ kind: 'event', role: 'system', text: 'personality changed' }) + pending = [] + + continue + } + if (display_kind === 'async_delegation_complete') { const meta = (row as TranscriptRow).display_metadata const count = meta && typeof meta.task_count === 'number' ? meta.task_count : undefined diff --git a/website/docs/developer-guide/programmatic-integration.md b/website/docs/developer-guide/programmatic-integration.md index 46e05aec050c1..ac7469d33eab7 100644 --- a/website/docs/developer-guide/programmatic-integration.md +++ b/website/docs/developer-guide/programmatic-integration.md @@ -63,8 +63,8 @@ A rewind / edit / regenerate is a `prompt.submit` that drops part of the stored | Parameter | Meaning | |-----------|---------| -| `truncate_before_user_ordinal` | Zero-based index of the user turn to cut at. Everything from that turn onward is dropped. Display-only timeline rows (`display_kind`) are not counted. | -| `confirm_truncate` | Required whenever an ordinal is sent. Declares that this submit really is a rewind, not an ordinary send that happens to carry a leftover ordinal. | +| `truncate_before_user_ordinal` | Zero-based index of the user turn to cut at. Everything from that turn onward is dropped. Display-only timeline rows (`display_kind`) are not counted. Must be a real integer — a JSON boolean is refused with code `4004`. | +| `confirm_truncate` | Required whenever an ordinal is sent. Declares that this submit really is a rewind, not an ordinary send that happens to carry a leftover ordinal. Sending it without an ordinal is refused with code `4004` (leaked rewind state). | | `confirm_empty_truncate` | Additionally required when the cut would leave the transcript empty (ordinal `0`). | An ordinal without `confirm_truncate` is refused with code `4029` and nothing is written. Hosts that implement rewind must set the flag at the moment the user asks for it, and must never keep the ordinal in state across ordinary submits.