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.