Files
hermes-agent/tests/agent/test_codex_app_server_transform.py
T
Teknium 6030e77121 fix(hold-stream): no side path delivers a held turn's original text
Independent review of the hold_stream feature found four paths that still
let the untransformed reply or commentary reach the user:

- History loads showed held commentary. The tool-call assistant row of a
  held turn is now typed display_kind="hidden" before its first flush
  (withhold_held_commentary), so TUI/Desktop display_projection, the
  dashboard and classic CLI resume skip it. display_kind never reaches the
  wire: the model's context and cached prefix are unchanged (test replays
  the resumed session and compares the request bytes). The TUI projection
  still labels the hidden row's tool cards from its calls. Codex held turns
  type their earlier agentMessages hidden the same way.
- A plugin reload mid-turn could empty the hook registry before the final
  transform. The held turn now snapshots its transform_llm_output callbacks
  together with the hold decision under the discovery lock
  (held_stream_transforms) and runs that snapshot at the final. If no
  callback rewrote and one raised or timed out, the user gets a fixed
  "reply withheld" notice instead of the original (fail closed).
- Classic CLI replay-gap recovery painted and spoke pre_transform_response.
  A held turn's result (generic and codex) no longer carries the original
  at all (public_pre_transform), so receipts, recovery and post-stream
  suffix painting only ever see the rewrite.
- Codex error-after-text returned the raw agentMessage. The codex reply is
  now transformed whenever it has text, also when the turn errored (and,
  when held, when interrupted).

Same class, also fixed: the end-of-stream scrubber flush recorded a held
tail as streamed text (an interrupted turn could return it as an
untransformed partial final), and quiet-mode CLI printed held commentary
via _vprint.
2026-10-25 06:18:48 -07:00

54 lines
2.4 KiB
Python

"""The codex app-server runtime runs ``transform_llm_output`` like the generic loop.
That runtime returns before ``finalize_turn``, so a rewriting plugin (redaction) must be applied
to the codex reply before its rows are persisted: the user (who, with ``hold_stream``, saw nothing
stream) receives the rewrite, and the stored assistant row is the rewrite too.
"""
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from agent.codex_runtime import run_codex_app_server_turn
from tests.fakes.redactor_plugin import RAW, REWRITE, redactor_home
def _codex_agent(final_text: str):
agent = MagicMock()
agent._codex_session = MagicMock() # a seeded session: no subprocess spawn
agent._codex_session.run_turn.return_value = SimpleNamespace(
interrupted=False, error=None, thread_id="thread-1", turn_id="turn-1", tool_iterations=0,
projected_messages=[{"role": "assistant", "content": final_text}], final_text=final_text,
should_retire=False)
agent._codex_session_prompt = None
agent.tool_progress_callback = None
agent._iters_since_skill = agent._skill_nudge_interval = 0
agent.valid_tool_names = set()
agent._session_db = None
agent._llm_output_transform = None
agent._hold_stream, agent._held_transforms = False, () # turn-start state; the hold has its own tests
agent.session_id = "sess-codex"
agent.platform = "cli"
return agent
def test_codex_reply_is_rewritten_before_it_is_returned_or_stored():
from hermes_cli import plugins
redactor_home(Path(os.environ["HERMES_HOME"]), "http://127.0.0.1:9/v1", hold=True)
plugins._reset_plugin_managers_for_tests()
try:
agent = _codex_agent(RAW)
result = run_codex_app_server_turn(
agent, user_message="config?", original_user_message="config?",
messages=[{"role": "user", "content": "config?"}], effective_task_id="task-1", turn_id="t1")
finally:
plugins._reset_plugin_managers_for_tests()
assert result["final_response"] == REWRITE
assert result["response_transformed"] is True and result["pre_transform_response"] == RAW
assert [m["content"] for m in result["messages"] if m["role"] == "assistant"] == [REWRITE]
# External memory keeps the reply the user got, not the text the plugin removed.
assert agent._sync_external_memory_for_turn.call_args.kwargs["final_response"] == REWRITE