fix: close sibling display_kind drops and ui-tui parity for #82756

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
This commit is contained in:
kshitij 2026-08-10 10:53:08 +05:30
parent 4d79bd3d02
commit 327f7efab8
6 changed files with 56 additions and 6 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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"),

View File

@ -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".

View File

@ -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

View File

@ -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.