diff --git a/Agent.md b/Agent.md index 198c227c..eeca0681 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1592) — import check: `uv run python -c "from emrg.client.app import run_client" +Python: `uv run pytest tests/ -v` (1594) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) diff --git a/tests/test_prompt_templates.py b/tests/test_prompt_templates.py new file mode 100644 index 00000000..11ac7452 --- /dev/null +++ b/tests/test_prompt_templates.py @@ -0,0 +1,184 @@ +"""Wiring guards for the task-type prompt templates. + +Why this file exists +-------------------- +The daemon renders every task prompt through jinja2 with +``undefined=jinja2.Undefined`` (see ``TaskHandler._build_evolution_prompt``). That +choice is deliberate — the old ``str.format()`` crashed on a missing placeholder — +but it has a sharp edge: a name that is not in the builder's context renders as an +**empty string** instead of raising. A typo therefore breaks nothing a test can see. +The prompt simply tells the agent to write into a blank path, or to follow a blank +instruction, and the whole suite stays green. + +These tests close that hole from both ends: + +1. ``test_every_prompt_template_renders_through_the_real_builder`` — every built-in + task type's template renders through the real builder (real context, real env) + with a *minimal* task config, so the ``{% if ... %}``-guarded optional sections + take their unset branch, and no unrendered ``{{ }}`` / ``{% %}`` tag is left. +2. ``test_no_prompt_names_a_placeholder_the_builder_does_not_provide`` — the same + templates re-rendered with ``StrictUndefined``, which *raises* on a missing name, + against the context the builder actually produced. That is the check which turns + a silent blank into a failure. The strict renderer is self-tested with a planted + typo, so a device that silently stops detecting anything cannot pass. + +Named limit: this pins the wiring, not the prose. It cannot show that an agent +follows the procedure, that optional fields (``{% if task.extra_prompt %}``, +``{% if project.description %}``) are set in the host's config, or that the prompt's +content is any good. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import jinja2 +import yaml + +from emrg.protocol import InstanceIdentity +from emrg.server import scheduler as mod +from emrg.server.scheduler import TaskHandler + +REPO_ROOT = Path(__file__).resolve().parents[1] +PROMPTS_DIR = REPO_ROOT / "emrg" / "server" + +# A reference to a variable or a block tag that survived rendering. +LEFTOVER_TAG = re.compile(r"\{\{|\{%") + +# Optional task/project fields the prompts guard on. Populated only by the strict +# render, so that a "missing" name there is a typo rather than a design choice. +FULL_TASK_CONFIG: dict = { + "project": "demoproj", + "role": "committer", + "author_id": "argszero", + "keywords": "demo, wiring", + "extra_prompt": "EXTRA-PROMPT-MARKER", + "allow_self_merge": True, +} +FULL_PROJECT_ENTRY: dict = { + "name": "demoproj", + "description": "PROJECT-DESCRIPTION-MARKER", +} + + +def _write_projects_yml(tmp_path: Path, name: str, entry: dict) -> Path: + project_dir = tmp_path / name + project_dir.mkdir(exist_ok=True) + row = {"path": str(project_dir)} + row.update(entry) + (tmp_path / "projects.yml").write_text( + yaml.safe_dump([row]), encoding="utf-8" + ) + return project_dir + + +def _make_handler( + tmp_path: Path, + monkeypatch, + template_name: str, + config: dict, + project_entry: dict | None = None, +) -> TaskHandler: + """A real handler, from a hermetic config dir, pointed at a real template.""" + monkeypatch.setattr(mod, "config_dir", lambda: tmp_path) + name = config.get("project", "demoproj") + _write_projects_yml(tmp_path, name, project_entry or {"name": name}) + return TaskHandler( + name="demo-task", + config=config, + interval=300, + identity=InstanceIdentity(), + template_path=PROMPTS_DIR / template_name, + ) + + +def _builtin_templates() -> list[tuple[str, str]]: + return sorted(mod.TASK_TEMPLATES.items()) + + +def test_every_prompt_template_renders_through_the_real_builder(tmp_path, monkeypatch) -> None: + """Each built-in task type renders, with the optional sections unset.""" + minimal = {"project": "demoproj"} + for task_type, filename in _builtin_templates(): + handler = _make_handler(tmp_path, monkeypatch, filename, minimal) + rendered = handler._build_evolution_prompt() + leftover = LEFTOVER_TAG.search(rendered) + assert leftover is None, ( + f"{task_type}/{filename}: unrendered {leftover.group()!r} left in the " + f"prompt (Jinja saw it as text, so the agent would too)" + ) + assert len(rendered) > 500, ( + f"{task_type}/{filename}: only {len(rendered)} chars rendered — " + f"almost everything came out empty" + ) + + +def test_no_prompt_names_a_placeholder_the_builder_does_not_provide( + tmp_path, monkeypatch +) -> None: + """StrictUndefined: a name the builder does not provide fails loudly. + + This is the guard that the daemon's own ``Undefined`` cannot give: rendering the + real templates against the real context with an env that raises on a missing + name. Without it, ``{{ evolution_cwd_typo }}`` renders as ``''`` and every test + in the suite stays green. + """ + real_env = jinja2.Environment + captured: dict = {} + + class _RecordingTemplate: + def __init__(self, inner: jinja2.Template) -> None: + self._inner = inner + + def render(self, *args, **kwargs) -> str: + captured.update(kwargs) + return self._inner.render(*args, **kwargs) + + class _RecordingEnvironment(real_env): # type: ignore[misc,valid-type] + def from_string(self, source, *args, **kwargs): + inner = real_env.from_string(self, source, *args, **kwargs) + return _RecordingTemplate(inner) + + monkeypatch.setattr(jinja2, "Environment", _RecordingEnvironment) + handler = _make_handler( + tmp_path, + monkeypatch, + "evolution_prompt.md", + dict(FULL_TASK_CONFIG), + dict(FULL_PROJECT_ENTRY), + ) + handler._build_evolution_prompt() + monkeypatch.setattr(jinja2, "Environment", real_env) + + # Self-test of the device: if the capture silently stopped working, the strict + # render below would pass for the wrong reason (nothing to check). + assert len(captured) >= 15, ( + f"captured only {len(captured)} context keys from the builder — the " + f"recording environment is not in the render path, so this test would " + f"prove nothing" + ) + assert "task" in captured and "project" in captured + + strict = real_env(undefined=jinja2.StrictUndefined) + with_typo = strict.from_string("{{ evolution_cwd_typo }}") + try: + with_typo.render(**captured) + except jinja2.UndefinedError: + pass + else: + raise AssertionError( + "the strict renderer did not raise on a planted typo — the check " + "below would be vacuous" + ) + + for task_type, filename in _builtin_templates(): + text = (PROMPTS_DIR / filename).read_text(encoding="utf-8") + try: + strict.from_string(text).render(**captured) + except jinja2.UndefinedError as exc: + raise AssertionError( + f"{task_type}/{filename} references a name the builder does not " + f"provide ({exc}); with the daemon's Undefined it would silently " + f"render as an empty string" + ) from exc