fix: retain Responses assistant replies in RPC session history

Carve the history-only implementation and regression from #104754
(b4bfa76facb43111a863b1256c89875e3f01fde0) by PLASMA-FR; omit
the unrelated locale-picker changes. Complement merged #104523.

Real serve + WebSocket reconnect: SQLite retains two rows; before,
resume/activate/history return only the user; after, both survive.
Plain-content control retains both rows in both arms. Native macOS
sleep and the full desktop symptom are not established by this probe.

Refs #68321
This commit is contained in:
Ahmad Al-Faqih
2026-09-07 00:23:29 -07:00
committed by Teknium
parent e412727e72
commit 9d1eec0ce0
3 changed files with 66 additions and 5 deletions

View File

@@ -0,0 +1,54 @@
"""RPC history must retain visible Responses-API assistant sidecars (#68321)."""
from __future__ import annotations
import threading
from hermes_state import SessionDB
import tui_gateway.server as server
def test_session_history_preserves_codex_message_items(tmp_path):
message_items = [
{
"type": "message",
"role": "assistant",
"phase": "final_answer",
"content": [{"type": "output_text", "text": "Persisted answer"}],
}
]
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("stored-session", source="desktop")
db.append_message(
"stored-session",
"assistant",
"",
codex_message_items=message_items,
)
previous_db = server._db
setattr(server, "_db", db)
server._sessions["runtime-session"] = {
"session_key": "stored-session",
"history": [],
"history_lock": threading.Lock(),
"running": False,
"agent": None,
}
try:
response = server.handle_request(
{"id": "1", "method": "session.history", "params": {"session_id": "runtime-session"}}
)
finally:
server._sessions.pop("runtime-session", None)
setattr(server, "_db", previous_db)
db.close()
assert isinstance(response, dict)
assert "error" not in response
assert response["result"]["count"] == 1
messages = response["result"]["messages"]
assert len(messages) == 1
assert messages[0]["role"] == "assistant"
assert messages[0]["text"] == ""
assert messages[0]["codex_message_items"] == message_items

View File

@@ -167,7 +167,13 @@ def _legacy_display_kind(role: str, text: str) -> str | None:
return "auto_continue" if role == "user" and text.lstrip().startswith(_AUTO_CONTINUE_NOTE_PREFIX) else None
_HISTORY_REASONING_KEYS = ("reasoning", "reasoning_content", "reasoning_details", "codex_reasoning_items")
_HISTORY_ASSISTANT_DETAIL_KEYS = (
"reasoning",
"reasoning_content",
"reasoning_details",
"codex_reasoning_items",
"codex_message_items",
)
_HISTORY_ROLES = frozenset({"user", "assistant", "tool", "system"})
@@ -205,9 +211,9 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
# `context` is an 80-char preview; ship args so a full-call renderer isn't truncated.
messages.append({"role": "tool", "name": name, "context": _tool_ctx(name, args), **({"args": args} if args else {})})
continue
# A reasoning-only assistant turn is kept so "Thinking…" still shows after resume/reload.
has_reasoning = role == "assistant" and any(m.get(key) for key in _HISTORY_REASONING_KEYS)
if not content_text.strip() and not has_reasoning:
# Assistant detail sidecars can carry the only visible reply or reasoning after resume/reload.
has_assistant_detail = role == "assistant" and any(m.get(key) for key in _HISTORY_ASSISTANT_DETAIL_KEYS)
if not content_text.strip() and not has_assistant_detail:
continue
msg = {"role": role, "text": content_text}
# Authoring time (Unix seconds) for display.timestamps; display-only.
@@ -223,7 +229,7 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
if invocation:
msg.update(text=invocation, display_kind="skill_invocation")
if role == "assistant":
msg.update((key, m[key]) for key in _HISTORY_REASONING_KEYS if m.get(key) is not None)
msg.update((key, m[key]) for key in _HISTORY_ASSISTANT_DETAIL_KEYS if m.get(key) is not None)
# Display-only timeline metadata (model switches, delegation events).
display_kind = m.get("display_kind") or _legacy_display_kind(role, content_text)
if display_kind:

View File

@@ -138,6 +138,7 @@ CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id, id);
Notes:
- `tool_calls` is stored as a JSON string (serialized list of tool call objects)
- `reasoning_details`, `codex_reasoning_items`, and `codex_message_items` are stored as JSON strings
- Desktop history hydration retains assistant sidecars in both REST and JSON-RPC (`session.resume`, `session.activate`, `session.history`) projections. A Responses reply may live only in `codex_message_items` while `content` is empty; that is not an empty transcript row.
- `reasoning` stores the raw reasoning text for providers that expose it
- `api_content` is a byte-fidelity sidecar: the exact content string sent to the API for this message when it differs from `content` (ephemeral memory/plugin injections, persist overrides). It preserves the wire bytes for prompt-cache-stable replay — stored as sent, except lone surrogates, which sqlite3 cannot bind and which the conversation loop scrubs from every outgoing payload anyway. `NULL` means `content` was sent verbatim.
- Timestamps are Unix epoch floats (`time.time()`)