diff --git a/tests/tui_gateway/test_session_history_codex_sidecar.py b/tests/tui_gateway/test_session_history_codex_sidecar.py new file mode 100644 index 0000000000..d20a7cb5c5 --- /dev/null +++ b/tests/tui_gateway/test_session_history_codex_sidecar.py @@ -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 diff --git a/tui_gateway/session_history.py b/tui_gateway/session_history.py index 998c79e16d..92f9373965 100644 --- a/tui_gateway/session_history.py +++ b/tui_gateway/session_history.py @@ -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: diff --git a/website/docs/developer-guide/session-storage.md b/website/docs/developer-guide/session-storage.md index 2e101bef02..0d1b2e51b5 100644 --- a/website/docs/developer-guide/session-storage.md +++ b/website/docs/developer-guide/session-storage.md @@ -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()`)