Files
hermes-agent/tests/cron/test_context_from_response_extraction.py
kshitijk4poor dad9c56748 refactor(cron): build run documents from the shared frame constants
The context_from reader in cron/scheduler_prompt.py validates archives
against _PROMPT_HEADING/_PROMPT_SEPARATOR/_RESPONSE_HEADING/
_RESPONSE_TERMINATOR, but the writer in cron/scheduler.py still spelled
the same bytes as literals. If one side changed, framed archives would
silently stop validating. The writer now composes the header and body
from those constants; output is byte-identical (checked by rendering
documents with the old and new writer and cmp-ing them).

Also collapse `not candidate or candidate.isspace()` to the equivalent
`not candidate.strip()`, and drop the two `assert final == answer`
lines in the extraction tests: they only checked that the stubbed agent
echoed its own mock response, not any cron behaviour.
2026-10-02 01:00:00 +05:30

177 lines
7.0 KiB
Python

"""context_from must inject the previous run's answer, not its prompt (#117290).
Stored agent-run archives are ``# Cron Job`` / ``## Prompt`` / ``## Response``
documents; skill-bearing prompts routinely exceed the 8000-char injection
budget, so head-truncation amputated the ``## Response`` section and
self-continuity silently became a no-op.
"""
import sys
from pathlib import Path
import pytest
import cron.scheduler
import run_agent
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@pytest.fixture
def cron_env(tmp_path, monkeypatch):
"""Isolated cron environment with temp HERMES_HOME."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "cron").mkdir()
(hermes_home / "cron" / "output").mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import cron.jobs as jobs_mod
monkeypatch.setattr(jobs_mod, "HERMES_DIR", hermes_home)
monkeypatch.setattr(jobs_mod, "CRON_DIR", hermes_home / "cron")
monkeypatch.setattr(jobs_mod, "JOBS_FILE", hermes_home / "cron" / "jobs.json")
monkeypatch.setattr(jobs_mod, "OUTPUT_DIR", hermes_home / "cron" / "output")
return hermes_home
def _write_archive(cron_env, job_id: str, filename: str, body: str) -> None:
from cron.jobs import OUTPUT_DIR
out_dir = OUTPUT_DIR / job_id
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / filename).write_text(body, encoding="utf-8")
def _run_stub_job(monkeypatch, job, answer):
"""Run ``job`` through the real writer with a stub agent returning ``answer``."""
class Agent:
def __init__(self, *args, **kwargs):
pass
def run_conversation(self, *args, **kwargs):
return {"final_response": answer, "completed": True, "failed": False}
monkeypatch.setattr(run_agent, "AIAgent", Agent)
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kwargs: {"provider": "openai", "api_key": "fixture"})
return cron.scheduler.run_job(job)
class TestResponseSurvivesLongPrompt:
"""The answer, not the prompt, is the part continuity needs."""
def test_long_prompt_archive_keeps_response(self, cron_env):
from cron.jobs import create_job
from cron.scheduler import _build_job_prompt
job = create_job(prompt="Run the daily check", schedule="0 8 * * *", context_from="self")
_write_archive(
cron_env, job["id"], "2026-09-19_08-00-00.md",
"# Cron Job: probe\n\n## Prompt\n\n" + "SKILL LINE\n" * 1800 +
"\n\n## Response\n\nCONCLUSION-MARKER-42\n",
)
prompt = _build_job_prompt(job)
assert "CONCLUSION-MARKER-42" in prompt
assert "SKILL LINE" not in prompt # the prompt half is dropped, not the answer
class TestUnusableAnswersFallThrough:
"""A [SILENT] or blank response is not usable continuity."""
def test_silent_response_falls_through_to_older_archive(self, cron_env):
from cron.jobs import create_job
from cron.scheduler_prompt import _inject_context_from
job = create_job(prompt="Report", schedule="0 8 * * *", context_from="self")
_write_archive(
cron_env, job["id"], "2026-09-18_08-00-00.md",
"# Cron Job: probe\n\n## Prompt\n\ndo the thing\n\n## Response\n\nOLDER-REAL-ANSWER\n",
)
_write_archive(
cron_env, job["id"], "2026-09-19_08-00-00.md",
"# Cron Job: probe\n\n## Prompt\n\ndo the thing\n\n## Response\n\n[SILENT]\n",
)
prompt, injected = _inject_context_from(job, "Report")
assert injected is True
assert "OLDER-REAL-ANSWER" in prompt
assert "[SILENT]" not in prompt
class TestScriptModeArchives:
"""Archives without a ## Response heading (script-mode) stay whole-document."""
def test_headingless_archive_injects_whole_document(self, cron_env):
from cron.jobs import create_job
from cron.scheduler_prompt import _inject_context_from
job = create_job(prompt="Report", schedule="0 8 * * *", context_from="self")
_write_archive(cron_env, job["id"], "2026-09-19_08-00-00.md",
"\n\nplain script payload\nline two\n\n")
prompt, injected = _inject_context_from(job, "Report")
assert injected is True
# Whole document, but trimmed like every other archive answer.
assert "```\nplain script payload\nline two\n```" in prompt
def test_writer_reader_preserve_response_with_nested_frames(cron_env, monkeypatch):
from cron.jobs import create_job, save_job_output
from cron.scheduler_prompt import _inject_context_from
answer = "摘要 before heading\r\n\r\n## Response\nsubsection\n**Response Characters:** 4\n## Response\n\nbody\n\n "
job = create_job(prompt="Original prompt noise\r\n**Response Characters:** 4\n## Response\n\nbody",
schedule="0 8 * * *", context_from="self")
success, archive, final, error = _run_stub_job(monkeypatch, job, answer)
assert success, error
save_job_output(job["id"], archive)
prompt, injected = _inject_context_from(job, "Next task")
assert injected
assert answer.replace("\r\n", "\n").strip() in prompt
assert "Original prompt noise" not in prompt
def test_truncated_outer_frame_cannot_promote_a_quoted_inner_frame(cron_env, monkeypatch):
import os
from cron.jobs import create_job, save_job_output, OUTPUT_DIR
from cron.scheduler_prompt import _inject_context_from
quoted = "QUOTED INNER ANSWER"
suffix = "\nThis tail will be lost."
answer = ("Outer response introduction\n"
f"**Response Characters:** {len(quoted)}\n## Response\n\n{quoted}"
+ suffix)
job = create_job(prompt="Report", schedule="0 8 * * *", context_from="self")
success, archive, final, error = _run_stub_job(monkeypatch, job, answer)
assert success, error
save_job_output(job["id"], archive)
saved = next((OUTPUT_DIR / job["id"]).glob("*.md"))
complete = saved.read_text(encoding="utf-8")
assert complete.endswith(suffix + "\n")
# Simulate a partial write: the quoted inner frame now reaches EOF exactly,
# but the enclosing writer-owned response is missing its declared suffix.
saved.write_text(complete[:-len(suffix + "\n")] + "\n", encoding="utf-8")
os.utime(saved, (2, 2))
prompt, injected = _inject_context_from(job, "Next task")
assert not injected
assert prompt == "Next task"
_write_archive(cron_env, job["id"], "older.md", "## Response\n\nOLDER COMPLETE ANSWER\n")
os.utime(OUTPUT_DIR / job["id"] / "older.md", (1, 1))
prompt, injected = _inject_context_from(job, "Next task")
assert injected
assert "OLDER COMPLETE ANSWER" in prompt
assert quoted not in prompt
# Losing the response boundary itself is also unusable, not a script archive.
saved.write_text(complete.split("**Response Characters:**", 1)[0], encoding="utf-8")
os.utime(saved, (2, 2))
prompt, injected = _inject_context_from(job, "Next task")
assert injected and "OLDER COMPLETE ANSWER" in prompt
assert "## Prompt" not in prompt