diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index f753372bc8..a02b01c69c 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5291,73 +5291,6 @@ def test_prompt_submit_truncates_by_row_id(monkeypatch): server._sessions.pop("row-id-trunc-sid", None) -def test_prompt_submit_row_id_truncates_profile_owned_history(monkeypatch, tmp_path): - """A remote-profile edit must write through the same DB used to resolve it.""" - from hermes_state import SessionDB - - profile_home = tmp_path / "remote-profile" - profile_home.mkdir() - session_key = "profile-row-id-session" - db = SessionDB(db_path=profile_home / "state.db") - try: - db.create_session(session_key, source="desktop") - db.append_messages_batch( - session_key, - [ - {"role": "user", "content": "first"}, - {"role": "assistant", "content": "reply 1"}, - {"role": "user", "content": "second"}, - {"role": "assistant", "content": "reply 2"}, - ], - ) - history = db.get_messages_as_conversation( - session_key, include_row_ids=True - ) - target_row_id = history[2]["_row_id"] - finally: - db.close() - - sess = _session(history=list(history), session_key=session_key) - sess["profile_home"] = str(profile_home) - server._sessions["profile-row-id-sid"] = sess - monkeypatch.setattr( - server, - "_get_db", - lambda: pytest.fail("must not write through the launch-profile DB"), - ) - monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None) - - try: - resp = server.handle_request( - { - "id": "1", - "method": "prompt.submit", - "params": { - "session_id": "profile-row-id-sid", - "text": "edited second", - "truncate_before_row_id": target_row_id, - "confirm_truncate": True, - }, - } - ) - assert resp.get("error") is None - assert [message["content"] for message in sess["history"]] == [ - "first", - "reply 1", - ] - verify_db = SessionDB(db_path=profile_home / "state.db") - try: - persisted = verify_db.get_messages_as_conversation(session_key) - finally: - verify_db.close() - assert [message["content"] for message in persisted] == [ - "first", - "reply 1", - ] - finally: - server._sessions.pop("profile-row-id-sid", None) - - def test_prompt_submit_truncates_by_string_row_id(monkeypatch): """#82959: String row IDs in history match correctly against integer truncate_before_row_id.""" replaced = [] diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index b9c1e8361b..71fe628a35 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -575,49 +575,48 @@ def _(rid, params: dict) -> dict: # new exchange is appended on top of the "undone" turns — durable # zombie history on resume, and the edit/regenerate never sticks. # Fail closed: refuse the turn and leave memory/DB unchanged. - with _session_db(session) as db: - if db is not None: - try: - # active_only=True: replace only the live (active=1) rows. - # In-place compaction (#38763) keeps the pre-compaction - # transcript as active=0/compacted=1 rows under this same - # session key; a bare replace_messages() would DELETE that - # durable archive on every edit/regenerate — the same bug - # class #80216 fixed for /retry. On an uncompacted session - # all rows are active=1, so this is behaviorally identical - # to the full replace. - # archive_dropped: a rewind overwrites turns the user may - # not have meant to drop, and this write is the last step - # before they are gone — three reported incidents ended - # here with nothing to restore from (#70516, #80763, - # #82756). Soft-archiving keeps them on disk (active=0) and - # in the FTS index, so a mis-aimed cut is recoverable - # instead of terminal. The live transcript is unchanged. - # Fall back to session id when session_key is NULL — CLI-origin - # sessions created before the session_key default fix have no - # key, and replace_messages(None) triggers an FK violation. - truncation_key = session.get("session_key") or sid - db.replace_messages( - truncation_key, - truncated, - active_only=True, - archive_dropped=True, - ) - except Exception as exc: - logger.error( - "prompt.submit: replace_messages failed for session %s " - "(ordinal=%d); refusing turn so memory and DB stay " - "aligned: %s", - sid, - ordinal, - exc, - exc_info=True, - ) - return _err( - rid, - 5008, - f"failed to persist history truncation: {exc}", - ) + if (db := _get_db()) is not None: + try: + # active_only=True: replace only the live (active=1) rows. + # In-place compaction (#38763) keeps the pre-compaction + # transcript as active=0/compacted=1 rows under this same + # session key; a bare replace_messages() would DELETE that + # durable archive on every edit/regenerate — the same bug + # class #80216 fixed for /retry. On an uncompacted session + # all rows are active=1, so this is behaviorally identical + # to the full replace. + # archive_dropped: a rewind overwrites turns the user may + # not have meant to drop, and this write is the last step + # before they are gone — three reported incidents ended + # here with nothing to restore from (#70516, #80763, + # #82756). Soft-archiving keeps them on disk (active=0) and + # in the FTS index, so a mis-aimed cut is recoverable + # instead of terminal. The live transcript is unchanged. + # Fall back to session id when session_key is NULL — CLI-origin + # sessions created before the session_key default fix have no + # key, and replace_messages(None) triggers an FK violation. + truncation_key = session.get("session_key") or sid + db.replace_messages( + truncation_key, + truncated, + active_only=True, + archive_dropped=True, + ) + except Exception as exc: + logger.error( + "prompt.submit: replace_messages failed for session %s " + "(ordinal=%d); refusing turn so memory and DB stay " + "aligned: %s", + sid, + ordinal, + exc, + exc_info=True, + ) + return _err( + rid, + 5008, + f"failed to persist history truncation: {exc}", + ) session["history"] = truncated session["history_version"] = int(session.get("history_version", 0)) + 1 if db is not None: