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:
54
tests/tui_gateway/test_session_history_codex_sidecar.py
Normal file
54
tests/tui_gateway/test_session_history_codex_sidecar.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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()`)
|
||||
|
||||
Reference in New Issue
Block a user