Maps solivajp@gmail.com to @jonpol01 for the check-attribution job and trims the two new-blank-line-at-EOF hunks git diff --check flags in the salvaged tests.
147 lines
7.5 KiB
Python
147 lines
7.5 KiB
Python
"""``hermes chat -Q`` as a dispatcher's re-run of a failed bot delivery (#111721).
|
|
|
|
A failed delivery turn persists its user row before the provider call; the policy-gated re-run
|
|
replays the same session and payload in a fresh process. Told so via HERMES_RESUME_UNANSWERED_TURN,
|
|
the re-run resumes that row instead of appending a second copy of the DM.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from types import SimpleNamespace
|
|
|
|
import cli
|
|
from agent.context_compressor import _DB_PERSISTED_MARKER
|
|
from tools.bot_relay import RESUME_UNANSWERED_TURN_ENV
|
|
|
|
|
|
def _quiet_turn(monkeypatch, history, marker):
|
|
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
|
|
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
|
if marker is None:
|
|
monkeypatch.delenv(RESUME_UNANSWERED_TURN_ENV, raising=False)
|
|
else:
|
|
monkeypatch.setenv(RESUME_UNANSWERED_TURN_ENV, marker)
|
|
seen = {}
|
|
|
|
def run_conversation(**kwargs):
|
|
seen["history"] = list(kwargs["conversation_history"])
|
|
seen["env"] = os.environ.get(RESUME_UNANSWERED_TURN_ENV)
|
|
return {"final_response": "ok"}
|
|
|
|
agent = SimpleNamespace(run_conversation=run_conversation, session_id="s-1")
|
|
the_cli = SimpleNamespace(agent=agent, conversation_history=history, session_id="s-1")
|
|
try:
|
|
cli._run_quiet_single_query(the_cli, "hello")
|
|
except SystemExit as exc:
|
|
assert exc.code == 0
|
|
return agent, seen
|
|
|
|
|
|
def test_rerun_resumes_the_unanswered_user_row_it_already_persisted(monkeypatch):
|
|
tail = {"role": "user", "content": "hello"}
|
|
agent, seen = _quiet_turn(monkeypatch, [{"role": "assistant", "content": "earlier"}, tail], "1")
|
|
# The persisted row leaves the history and becomes this turn's staged user dict, already
|
|
# durable — so the agent reuses it as the turn's user message and the flush writes no new row.
|
|
assert seen["history"] == [{"role": "assistant", "content": "earlier"}]
|
|
assert agent._pending_cli_user_message is tail
|
|
assert tail[_DB_PERSISTED_MARKER] is True
|
|
assert seen["env"] is None, "the marker is consumed before the turn so tool subprocesses never inherit it"
|
|
|
|
|
|
def test_without_the_marker_or_an_unanswered_tail_nothing_is_adopted(monkeypatch):
|
|
"""A person re-sending the same word after a failed turn has sent a second real message; an
|
|
answered tail is not a re-run either. Only the dispatcher's explicit marker plus an identical
|
|
unanswered tail adopts a row."""
|
|
tail = {"role": "user", "content": "hello"}
|
|
agent, seen = _quiet_turn(monkeypatch, [tail], None)
|
|
assert seen["history"] == [tail] and not hasattr(agent, "_pending_cli_user_message")
|
|
assert _DB_PERSISTED_MARKER not in tail
|
|
|
|
answered = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "done"}]
|
|
agent, seen = _quiet_turn(monkeypatch, list(answered), "1")
|
|
assert seen["history"] == answered and not hasattr(agent, "_pending_cli_user_message")
|
|
|
|
|
|
def test_rerun_adopts_the_dm_behind_the_failed_attempts_tool_scaffolding(monkeypatch):
|
|
"""A 503 after a tool round persisted user + assistant(tool_calls) + tool before the failure text,
|
|
and the dispatcher retries it. The DM is still unanswered: the re-run adopts it and drops the
|
|
failed attempt's scaffolding from the in-memory turn instead of appending a second copy."""
|
|
tail = {"role": "user", "content": "hello"}
|
|
scaffolding = [
|
|
{"role": "assistant", "content": None, "tool_calls": [{"id": "c1", "type": "function",
|
|
"function": {"name": "t", "arguments": "{}"}}]},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "result"},
|
|
]
|
|
agent, seen = _quiet_turn(monkeypatch, [{"role": "assistant", "content": "earlier"}, tail, *scaffolding], "1")
|
|
assert seen["history"] == [{"role": "assistant", "content": "earlier"}]
|
|
assert agent._pending_cli_user_message is tail and tail[_DB_PERSISTED_MARKER] is True
|
|
|
|
|
|
def test_turn_report_is_written_before_the_exit_linger_and_the_path_is_not_inherited(monkeypatch, tmp_path):
|
|
"""A spawner that bounds only the turn (cron Bot Chat lane, #113608) reads the outcome from
|
|
HERMES_QUIET_TURN_REPORT_FILE: written the moment the turn ends — before the one-shot exit
|
|
linger — stamped with this pid, and the variable is popped before the turn spawns anything."""
|
|
from hermes_cli import quiet_single_query as qsq
|
|
|
|
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
|
|
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
|
report = tmp_path / "turn.json"
|
|
monkeypatch.setenv(qsq.TURN_REPORT_FILE_ENV, str(report))
|
|
seen = {}
|
|
|
|
def run_conversation(**kwargs):
|
|
seen["env_during_turn"] = os.environ.get(qsq.TURN_REPORT_FILE_ENV)
|
|
seen["report_during_turn"] = report.exists()
|
|
return {"final_response": "ok"}
|
|
|
|
def linger(*args, **kwargs):
|
|
seen["report_at_linger"] = qsq.read_turn_report(str(report), os.getpid())
|
|
return {"waited": [], "completed": [], "timed_out": []}
|
|
|
|
monkeypatch.setattr("tools.process_registry.process_registry.wait_for_pending_completions", linger)
|
|
agent = SimpleNamespace(run_conversation=run_conversation, session_id="s-1")
|
|
try:
|
|
cli._run_quiet_single_query(SimpleNamespace(agent=agent, conversation_history=[], session_id="s-1"), "hello")
|
|
except SystemExit as exc:
|
|
assert exc.code == 0
|
|
assert seen["env_during_turn"] is None and seen["report_during_turn"] is False
|
|
assert seen["report_at_linger"] == {"pid": os.getpid(), "exit_code": 0, "error": "", "reply": "ok"}
|
|
# Another process's record is not this child's report.
|
|
assert qsq.read_turn_report(str(report), os.getpid() + 1) is None
|
|
|
|
|
|
def test_a_follow_up_turn_rewrites_the_report_with_the_answer_it_displaces(monkeypatch, tmp_path):
|
|
"""The report says what this run will print. When a teammate's reply during the linger runs a
|
|
follow-up turn whose answer displaces the first (the quiet run's final-answer contract), the
|
|
report is rewritten with it, so a relay booking the child at its cap relays that answer (#114980)."""
|
|
from hermes_cli import quiet_single_query as qsq
|
|
|
|
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
|
|
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
|
report = tmp_path / "turn.json"
|
|
monkeypatch.setenv(qsq.TURN_REPORT_FILE_ENV, str(report))
|
|
answers = iter(["asking the teammate", "teammate says: done"])
|
|
seen = {}
|
|
|
|
def run_conversation(**kwargs):
|
|
return {"final_response": next(answers), "messages": []}
|
|
|
|
def linger_then_follow_up(session_id, run_turn, **kwargs):
|
|
seen["report_before_follow_up"] = qsq.read_turn_report(str(report), os.getpid())["reply"]
|
|
return run_turn("teammate: done")
|
|
|
|
monkeypatch.setattr(qsq, "continue_quiet_notify_completions", linger_then_follow_up)
|
|
monkeypatch.setattr("tools.process_registry.process_registry.wait_for_pending_completions",
|
|
lambda *a, **k: {"waited": [], "completed": [], "timed_out": []})
|
|
agent = SimpleNamespace(run_conversation=run_conversation, session_id="s-1")
|
|
printed = []
|
|
monkeypatch.setattr("builtins.print", lambda *a, **k: printed.append(a))
|
|
try:
|
|
cli._run_quiet_single_query(SimpleNamespace(agent=agent, conversation_history=[], session_id="s-1"), "hello")
|
|
except SystemExit as exc:
|
|
assert exc.code == 0
|
|
assert seen["report_before_follow_up"] == "asking the teammate"
|
|
assert qsq.read_turn_report(str(report), os.getpid())["reply"] == "teammate says: done"
|
|
assert ("teammate says: done",) in printed, "the report and stdout name the same answer"
|