From 2d2311bdac42cb9a0c5e07758dec79cad87fc543 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:45:46 -0400 Subject: [PATCH 1/8] =?UTF-8?q?feat(tools):=20compile=5Fdocument=20?= =?UTF-8?q?=E2=80=94=20guarded=20LaTeX-to-PDF=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs latexmk/pdflatex as a guarded subprocess (no shell-escape, timeout, scoped dir) behind _run/_which seams; structured result. Host TeX engine, detect-and-instruct. Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- src/agentic_cli/tools/document/__init__.py | 5 + src/agentic_cli/tools/document/compile.py | 171 +++++++++++++++++++++ tests/tools/test_document_compile.py | 108 +++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 src/agentic_cli/tools/document/__init__.py create mode 100644 src/agentic_cli/tools/document/compile.py create mode 100644 tests/tools/test_document_compile.py diff --git a/src/agentic_cli/tools/document/__init__.py b/src/agentic_cli/tools/document/__init__.py new file mode 100644 index 0000000..6ebd9c8 --- /dev/null +++ b/src/agentic_cli/tools/document/__init__.py @@ -0,0 +1,5 @@ +"""Document-generation tools (LaTeX → PDF).""" + +from agentic_cli.tools.document.compile import compile_document + +__all__ = ["compile_document"] diff --git a/src/agentic_cli/tools/document/compile.py b/src/agentic_cli/tools/document/compile.py new file mode 100644 index 0000000..368c496 --- /dev/null +++ b/src/agentic_cli/tools/document/compile.py @@ -0,0 +1,171 @@ +"""Compile a LaTeX document to PDF with a host TeX engine. + +``compile_document`` is a narrow, permission-gated tool: it runs ``latexmk`` +(preferred) or ``pdflatex`` as a guarded subprocess — no shell-escape, a +wall-clock timeout, and a scoped working dir — and returns a structured result. +It does NOT execute arbitrary code; a ``report_writer``-style agent uses it to +turn an authored ``.tex`` into a PDF. + +Provisioning is host-based: the engine must be on ``PATH`` (TeX Live / MacTeX). +If neither is found the tool returns a structured error with an install hint. + +The subprocess call and the engine lookup sit behind module-level seams +(``_run``, ``_which``) so the logic is unit-tested offline without a real TeX +install. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any + +from agentic_cli.tools.registry import ToolCategory, register_tool +from agentic_cli.workflow.permissions import Capability + +_ENGINES = ("latexmk", "pdflatex") +_LOG_TAIL_LINES = 40 + + +def _which(name: str) -> str | None: + """Locate an executable on PATH (seam for tests).""" + return shutil.which(name) + + +def _run( + argv: list[str], *, cwd: str, env: dict[str, str], timeout: float +) -> subprocess.CompletedProcess: + """Run a subprocess capturing output (seam for tests).""" + return subprocess.run( + argv, cwd=cwd, env=env, capture_output=True, text=True, timeout=timeout + ) + + +def _detect_engine(engine: str | None) -> str | None: + """Return the engine to use, or None if unavailable.""" + if engine is not None: + return engine if _which(engine) else None + for candidate in _ENGINES: + if _which(candidate): + return candidate + return None + + +def _build_argv(engine: str, source: str) -> list[str]: + """Compiler argv — never enables shell-escape.""" + if engine == "latexmk": + return ["latexmk", "-pdf", "-interaction=nonstopmode", "-halt-on-error", source] + return [ + "pdflatex", "-no-shell-escape", "-interaction=nonstopmode", + "-halt-on-error", source, + ] + + +def _parse_errors(log_text: str) -> list[str]: + """Extract LaTeX error lines (those beginning with '!') from a log.""" + return [ln for ln in log_text.splitlines() if ln.startswith("!")] + + +@register_tool( + category=ToolCategory.EXECUTION, + capabilities=[Capability("document.compile", target_arg="source_path")], + description=( + "Compile a LaTeX source file to PDF using a host TeX engine (latexmk or " + "pdflatex), with shell-escape disabled. Returns the PDF path plus any " + "compiler errors. Requires TeX Live/MacTeX on PATH." + ), +) +def compile_document( + source_path: str, + output_pdf: str | None = None, + assets_dir: str | None = None, + engine: str | None = None, + timeout_s: int = 120, +) -> dict[str, Any]: + """Compile a LaTeX file to PDF (guarded subprocess; host TeX engine). + + Args: + source_path: Path to the .tex file to compile. + output_pdf: If set, the produced PDF is copied here (parents created); + build intermediates stay in the source's directory. + assets_dir: Directory prepended to TEXINPUTS so figures/resources resolve + by bare name (e.g. an artifacts dir). + engine: Force an engine ("latexmk"/"pdflatex"); default auto-detects + (latexmk preferred). + timeout_s: Wall-clock timeout; the process is killed on expiry. + + Returns: + dict with success, pdf_path, engine, log_tail, errors, duration_ms, and + (on setup/timeout failure) error. + """ + src = Path(source_path) + if not src.is_file(): + return { + "success": False, "error": f"Source not found: {source_path}", + "pdf_path": None, "engine": None, "log_tail": "", "errors": [], + "duration_ms": 0, + } + + chosen = _detect_engine(engine) + if chosen is None: + looked = engine or "/".join(_ENGINES) + return { + "success": False, + "error": ( + f"No LaTeX engine on PATH (looked for {looked}). " + "Install TeX Live or MacTeX." + ), + "pdf_path": None, "engine": None, "log_tail": "", "errors": [], + "duration_ms": 0, + } + + work_dir = src.parent + env = dict(os.environ) + if assets_dir: + prev = env.get("TEXINPUTS", "") + # Prepend assets_dir; trailing empty entry preserves the default path. + env["TEXINPUTS"] = f"{assets_dir}{os.pathsep}{prev}{os.pathsep}" + + argv = _build_argv(chosen, src.name) + start = time.monotonic() + try: + proc = _run(argv, cwd=str(work_dir), env=env, timeout=float(timeout_s)) + except subprocess.TimeoutExpired: + return { + "success": False, "error": f"Compilation timed out after {timeout_s}s", + "pdf_path": None, "engine": chosen, "log_tail": "", "errors": [], + "duration_ms": int((time.monotonic() - start) * 1000), + } + duration_ms = int((time.monotonic() - start) * 1000) + + log_path = work_dir / (src.stem + ".log") + log_text = ( + log_path.read_text(errors="replace") if log_path.is_file() + else (proc.stdout or "") + ) + log_tail = "\n".join(log_text.splitlines()[-_LOG_TAIL_LINES:]) + produced = work_dir / (src.stem + ".pdf") + success = proc.returncode == 0 and produced.is_file() + + if not success: + return { + "success": False, "pdf_path": None, "engine": chosen, + "log_tail": log_tail, "errors": _parse_errors(log_text), + "duration_ms": duration_ms, "error": None, + } + + final = produced + if output_pdf: + dest = Path(output_pdf) + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(produced, dest) + final = dest + + return { + "success": True, "pdf_path": str(final), "engine": chosen, + "log_tail": log_tail, "errors": [], "duration_ms": duration_ms, + "error": None, + } diff --git a/tests/tools/test_document_compile.py b/tests/tools/test_document_compile.py new file mode 100644 index 0000000..d6861e9 --- /dev/null +++ b/tests/tools/test_document_compile.py @@ -0,0 +1,108 @@ +"""Offline tests for compile_document — subprocess and engine lookup faked.""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +from agentic_cli.tools.document import compile as mod +from agentic_cli.tools.document import compile_document + + +def _fake_engine(monkeypatch, name="latexmk"): + monkeypatch.setattr(mod, "_which", lambda n: f"/usr/bin/{n}" if n == name else None) + + +def test_no_engine_returns_structured_error(monkeypatch, tmp_path): + monkeypatch.setattr(mod, "_which", lambda n: None) + tex = tmp_path / "r.tex"; tex.write_text("x") + r = compile_document(str(tex)) + assert r["success"] is False + assert "No LaTeX engine" in r["error"] + assert r["engine"] is None + + +def test_missing_source_returns_error(tmp_path): + r = compile_document(str(tmp_path / "nope.tex")) + assert r["success"] is False and "not found" in r["error"] + + +def test_success_places_pdf_and_keeps_intermediates(monkeypatch, tmp_path): + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex" + tex.write_text("\\documentclass{article}\\begin{document}hi\\end{document}") + + def fake_run(argv, *, cwd, env, timeout): + (Path(cwd) / "r.pdf").write_bytes(b"%PDF-1.5 fake") + (Path(cwd) / "r.log").write_text("output written on r.pdf") + (Path(cwd) / "r.aux").write_text("\\relax") + return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + out = tmp_path / "deliver" / "report.pdf" + r = compile_document(str(tex), output_pdf=str(out), assets_dir=str(tmp_path / "assets")) + assert r["success"] is True + assert r["pdf_path"] == str(out) + assert out.is_file() # PDF promoted to delivery dir + assert (tmp_path / "r.aux").is_file() # intermediates stay in build dir + assert not (out.parent / "r.aux").exists() # not beside the delivered PDF + + +def test_failure_parses_errors(monkeypatch, tmp_path): + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("bad") + + def fake_run(argv, *, cwd, env, timeout): + (Path(cwd) / "r.log").write_text("! Undefined control sequence.\nl.5 \\badcmd\n") + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + r = compile_document(str(tex)) + assert r["success"] is False + assert any("Undefined control sequence" in e for e in r["errors"]) + assert r["pdf_path"] is None + + +def test_argv_never_enables_shell_escape(monkeypatch, tmp_path): + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("x") + captured = {} + + def fake_run(argv, *, cwd, env, timeout): + captured["argv"] = argv; captured["env"] = env + (Path(cwd) / "r.pdf").write_bytes(b"%PDF") + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + compile_document(str(tex), assets_dir="/tmp/assets") + assert "-shell-escape" not in captured["argv"] + assert "-halt-on-error" in captured["argv"] + assert captured["argv"][0] == "latexmk" + assert "/tmp/assets" in captured["env"]["TEXINPUTS"] + + +def test_pdflatex_uses_no_shell_escape_flag(monkeypatch, tmp_path): + _fake_engine(monkeypatch, name="pdflatex") + tex = tmp_path / "r.tex"; tex.write_text("x") + captured = {} + + def fake_run(argv, *, cwd, env, timeout): + captured["argv"] = argv + (Path(cwd) / "r.pdf").write_bytes(b"%PDF") + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + compile_document(str(tex)) + assert captured["argv"][0] == "pdflatex" + assert "-no-shell-escape" in captured["argv"] + + +def test_timeout_returns_structured_failure(monkeypatch, tmp_path): + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("x") + + def fake_run(argv, *, cwd, env, timeout): + raise subprocess.TimeoutExpired(argv, timeout) + + monkeypatch.setattr(mod, "_run", fake_run) + r = compile_document(str(tex), timeout_s=1) + assert r["success"] is False and "timed out" in r["error"] From a4a4cc5bd05320fa8dae462dbf9c6c74669c9f14 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:53:53 -0400 Subject: [PATCH 2/8] fix(tools): compile_document never raises on run/copy failures; validate forced engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Catch FileNotFoundError/OSError from _run alongside TimeoutExpired - Wrap mkdir+copy2 PDF delivery in try/except OSError; return pdf_path to build dir - _detect_engine: reject engine not in _ENGINES even if _which finds it - TDD: 3 new tests (RED→GREEN) in test_document_compile.py; 10 passing total Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- src/agentic_cli/tools/document/compile.py | 20 +++++++-- tests/tools/test_document_compile.py | 49 +++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/agentic_cli/tools/document/compile.py b/src/agentic_cli/tools/document/compile.py index 368c496..30061b9 100644 --- a/src/agentic_cli/tools/document/compile.py +++ b/src/agentic_cli/tools/document/compile.py @@ -47,7 +47,7 @@ def _run( def _detect_engine(engine: str | None) -> str | None: """Return the engine to use, or None if unavailable.""" if engine is not None: - return engine if _which(engine) else None + return engine if (engine in _ENGINES and _which(engine)) else None for candidate in _ENGINES: if _which(candidate): return candidate @@ -139,6 +139,12 @@ def compile_document( "pdf_path": None, "engine": chosen, "log_tail": "", "errors": [], "duration_ms": int((time.monotonic() - start) * 1000), } + except (FileNotFoundError, OSError) as exc: + return { + "success": False, "error": f"Failed to run {chosen}: {exc}", + "pdf_path": None, "engine": chosen, "log_tail": "", "errors": [], + "duration_ms": int((time.monotonic() - start) * 1000), + } duration_ms = int((time.monotonic() - start) * 1000) log_path = work_dir / (src.stem + ".log") @@ -160,8 +166,16 @@ def compile_document( final = produced if output_pdf: dest = Path(output_pdf) - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(produced, dest) + try: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(produced, dest) + except OSError as exc: + return { + "success": False, + "error": f"Failed to deliver PDF to {output_pdf}: {exc}", + "pdf_path": str(produced), "engine": chosen, + "log_tail": log_tail, "errors": [], "duration_ms": duration_ms, + } final = dest return { diff --git a/tests/tools/test_document_compile.py b/tests/tools/test_document_compile.py index d6861e9..487bdef 100644 --- a/tests/tools/test_document_compile.py +++ b/tests/tools/test_document_compile.py @@ -106,3 +106,52 @@ def fake_run(argv, *, cwd, env, timeout): monkeypatch.setattr(mod, "_run", fake_run) r = compile_document(str(tex), timeout_s=1) assert r["success"] is False and "timed out" in r["error"] + + +# --- Fix wave 1 tests --- + +def test_run_raises_file_not_found_returns_structured_error(monkeypatch, tmp_path): + """Finding 1: _run raising FileNotFoundError must not propagate; must return failure dict.""" + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("x") + + def fake_run(argv, *, cwd, env, timeout): + raise FileNotFoundError("latexmk: not found") + + monkeypatch.setattr(mod, "_run", fake_run) + r = compile_document(str(tex)) + assert r["success"] is False + assert r["error"] is not None and len(r["error"]) > 0 + assert r["engine"] == "latexmk" + assert r["pdf_path"] is None + assert "duration_ms" in r + + +def test_pdf_copy_oserror_returns_structured_error(monkeypatch, tmp_path): + """Finding 2: OSError during PDF delivery must not propagate; must return failure dict.""" + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("x") + out = tmp_path / "deliver" / "report.pdf" + + def fake_run(argv, *, cwd, env, timeout): + (Path(cwd) / "r.pdf").write_bytes(b"%PDF-1.5 fake") + (Path(cwd) / "r.log").write_text("output written on r.pdf") + return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + monkeypatch.setattr(mod.shutil, "copy2", lambda src, dst: (_ for _ in ()).throw(OSError("disk full"))) + + r = compile_document(str(tex), output_pdf=str(out)) + assert r["success"] is False + assert "deliver" in r["error"].lower() or str(out) in r["error"] + assert r["pdf_path"] is not None # PDF still exists in build dir + assert "duration_ms" in r + + +def test_forced_unsupported_engine_rejected(monkeypatch, tmp_path): + """Finding 3: forcing engine='xelatex' (not in _ENGINES) must be rejected → No LaTeX engine error.""" + monkeypatch.setattr(mod, "_which", lambda n: f"/usr/bin/{n}" if n == "xelatex" else None) + tex = tmp_path / "r.tex"; tex.write_text("x") + r = compile_document(str(tex), engine="xelatex") + assert r["success"] is False + assert "No LaTeX engine" in r["error"] From 6334023bf67221eac224d1e20faea2e8d0754b06 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:59:48 -0400 Subject: [PATCH 3/8] feat(tools): export compile_document; add gated real-LaTeX test + latex marker Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- pyproject.toml | 1 + src/agentic_cli/tools/__init__.py | 2 ++ tests/tools/test_document_compile_latex.py | 35 ++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/tools/test_document_compile_latex.py diff --git a/pyproject.toml b/pyproject.toml index 033cc9b..83b60b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,7 @@ asyncio_default_fixture_loop_scope = "function" markers = [ "llm: tests that require real LLM API calls (deselect with -m 'not llm')", "docker: tests that require a real container runtime (select with -m docker; set SANDBOX_REQUIRE_DOCKER=1 to fail instead of skip when absent)", + "latex: tests that require a host TeX engine (select with -m latex; set LATEX_REQUIRE=1 to fail instead of skip when absent)", ] [tool.ruff] diff --git a/src/agentic_cli/tools/__init__.py b/src/agentic_cli/tools/__init__.py index 2ce80af..b666ae9 100644 --- a/src/agentic_cli/tools/__init__.py +++ b/src/agentic_cli/tools/__init__.py @@ -50,6 +50,7 @@ fetch_arxiv_paper, ) from agentic_cli.tools.execution_tools import execute_python +from agentic_cli.tools.document import compile_document from agentic_cli.tools.interaction_tools import ask_clarification # Long-running job tools (generic observe-only management). Typed long-running @@ -126,6 +127,7 @@ "search_arxiv", "fetch_arxiv_paper", "execute_python", + "compile_document", "ask_clarification", # Long-running jobs (observe-only; typed starters are app-provided) "job_status", diff --git a/tests/tools/test_document_compile_latex.py b/tests/tools/test_document_compile_latex.py new file mode 100644 index 0000000..8fec1c2 --- /dev/null +++ b/tests/tools/test_document_compile_latex.py @@ -0,0 +1,35 @@ +"""Real-engine compile — skipped unless a TeX engine is installed. + +Set LATEX_REQUIRE=1 to fail (not skip) when no engine is present. +""" +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import pytest + +from agentic_cli.tools import compile_document + +pytestmark = pytest.mark.latex + +_HAS_ENGINE = shutil.which("latexmk") or shutil.which("pdflatex") +if not _HAS_ENGINE and os.environ.get("LATEX_REQUIRE") != "1": + pytest.skip("no LaTeX engine (latexmk/pdflatex) on PATH", allow_module_level=True) + + +def test_compiles_minimal_document(tmp_path): + build = tmp_path / "build"; build.mkdir() + tex = build / "r.tex" + tex.write_text( + "\\documentclass{article}\n\\begin{document}\n" + "Hello \\textbf{world}.\n\\end{document}\n" + ) + out = tmp_path / "deliver" / "report.pdf" + r = compile_document(str(tex), output_pdf=str(out)) + assert r["success"] is True, r + assert Path(r["pdf_path"]).is_file() and Path(r["pdf_path"]).stat().st_size > 0 + assert out.is_file() + assert (build / "r.log").is_file() # intermediates in build dir + assert not (out.parent / "r.log").exists() # not beside delivered PDF From fe730538ce0e76bedc016168daeaecd4d360e1cc Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:09:46 -0400 Subject: [PATCH 4/8] feat(research_demo): report_writer skill (knowledge-only LaTeX report package) Add tests/examples/conftest.py to enable ADK's SNAKE_CASE_SKILL_NAME feature flag (required in ADK >= 1.36 for underscore skill names). Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- .../skills/report_writer/SKILL.md | 62 +++++++++++++++++++ .../report_writer/assets/report_template.tex | 52 ++++++++++++++++ tests/examples/conftest.py | 11 ++++ tests/examples/test_report_writer_skill.py | 33 ++++++++++ 4 files changed, 158 insertions(+) create mode 100644 examples/research_demo/skills/report_writer/SKILL.md create mode 100644 examples/research_demo/skills/report_writer/assets/report_template.tex create mode 100644 tests/examples/conftest.py create mode 100644 tests/examples/test_report_writer_skill.py diff --git a/examples/research_demo/skills/report_writer/SKILL.md b/examples/research_demo/skills/report_writer/SKILL.md new file mode 100644 index 0000000..c51dc3b --- /dev/null +++ b/examples/research_demo/skills/report_writer/SKILL.md @@ -0,0 +1,62 @@ +--- +name: report_writer +description: Write and compile a LaTeX analysis report to PDF from figures and tables already produced in the run's artifacts directory. Use when the user wants a written or PDF report of a completed analysis. +--- + +# report_writer + +Produce a compiled PDF analysis report with LaTeX. You author a `.tex` file and +compile it with the `compile_document` tool. You do NOT run arbitrary code. + +## Inputs + +Analysis deliverables (figures as `.png`, tables as `.csv`) live in the run's +**artifacts directory** — the concrete path is in your instructions. Before +writing, list it with `glob` to learn the exact figure filenames: + + glob("/*.png") + +Reference figures by **bare filename** (e.g. `accuracy.png`), never full paths — +`compile_document`'s `assets_dir` makes them resolvable. + +## Report structure + +Write these sections, in order: + +1. **Introduction** — the question and why it matters. +2. **Methods** — the data and how it was analyzed. +3. **Results** — the findings, with figures/tables embedded and referenced. +4. **Discussion** — what the results mean; limitations. +5. **References** — a plain list of sources (no citation engine). + +## Authoring + +1. Load the template: `load_skill_resource("report_writer", "assets/report_template.tex")`. +2. Fill the placeholders (title, author/date, section prose, figure includes, table rows). +3. Escape LaTeX specials in prose: `% & _ # $ { }` → `\% \& \_ \# \$ \{ \}`. +4. `write_file` the filled source to the build directory as `report.tex`. + +## Compile + +Call the tool: + + compile_document( + source_path="/report.tex", + output_pdf="/report.pdf", + assets_dir="", + ) + +- On `success: true`, tell the user the report is at `pdf_path`. Done. +- On `success: false`, read `errors` and `log_tail`, fix the `.tex`, and recompile. + **Retry at most 3 times**, then report the failure with the error if still failing. + +## Common errors → fixes + +- `! Undefined control sequence` — a command from a package you did not + `\usepackage`. Add the package or use a base-LaTeX equivalent. +- `! LaTeX Error: File '' not found` — a figure name is wrong; re-`glob` + the artifacts dir and use the exact filename. +- `! Missing $ inserted` / `! You can't use ...` — an unescaped special + character in prose; escape it. +- `No LaTeX engine on PATH` — the host has no TeX install; tell the user to + install TeX Live or MacTeX. You cannot compile without it. diff --git a/examples/research_demo/skills/report_writer/assets/report_template.tex b/examples/research_demo/skills/report_writer/assets/report_template.tex new file mode 100644 index 0000000..6aa7860 --- /dev/null +++ b/examples/research_demo/skills/report_writer/assets/report_template.tex @@ -0,0 +1,52 @@ +\documentclass[11pt]{article} +\usepackage{graphicx} + +% === Fill in: title / author / date === +\title{TITLE_PLACEHOLDER} +\author{AUTHOR_PLACEHOLDER} +\date{DATE_PLACEHOLDER} + +\begin{document} +\maketitle + +\section{Introduction} +% Fill in: the question and why it matters. +INTRO_PLACEHOLDER + +\section{Methods} +% Fill in: the data and how it was analyzed. +METHODS_PLACEHOLDER + +\section{Results} +% Fill in: findings. Embed figures by BARE filename, e.g.: +% \begin{figure}[h] +% \centering +% \includegraphics[width=0.8\textwidth]{accuracy.png} +% \caption{Caption text.} +% \end{figure} +% +% Simple table (base LaTeX, no extra packages): +% \begin{table}[h] +% \centering +% \begin{tabular}{l r} +% \hline +% Metric & Value \\ +% \hline +% Accuracy & 0.789 \\ +% \hline +% \end{tabular} +% \caption{Caption text.} +% \end{table} +RESULTS_PLACEHOLDER + +\section{Discussion} +% Fill in: interpretation and limitations. +DISCUSSION_PLACEHOLDER + +\section*{References} +% Fill in: a plain list of sources. +\begin{itemize} + \item REFERENCE_PLACEHOLDER +\end{itemize} + +\end{document} diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py new file mode 100644 index 0000000..47a8bd7 --- /dev/null +++ b/tests/examples/conftest.py @@ -0,0 +1,11 @@ +"""Shared fixtures for tests/examples. + +Sets ADK feature flags required for the examples test suite. +""" + +import os + +# ADK >= 1.36 requires SNAKE_CASE_SKILL_NAME feature flag to accept +# underscore-style skill names (e.g. report_writer). Set before any test +# imports google.adk so the flag is seen by every call to is_feature_enabled. +os.environ.setdefault("ADK_ENABLE_SNAKE_CASE_SKILL_NAME", "1") diff --git a/tests/examples/test_report_writer_skill.py b/tests/examples/test_report_writer_skill.py new file mode 100644 index 0000000..9349cd6 --- /dev/null +++ b/tests/examples/test_report_writer_skill.py @@ -0,0 +1,33 @@ +"""Resolution + asset tests for the demo report_writer skill (no LLM).""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("google.adk") + +from agentic_cli.tools.skills import SkillStore, make_skill_toolset + +_SKILLS_DIR = Path(__file__).resolve().parents[2] / "examples" / "research_demo" / "skills" + + +def test_report_writer_skill_resolves(): + skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report_writer"]) + assert len(skills) == 1 + assert skills[0].name == "report_writer" + assert skills[0].description # non-empty when-to-use + + +def test_report_writer_template_asset_present(): + tpl = _SKILLS_DIR / "report_writer" / "assets" / "report_template.tex" + assert tpl.is_file() + body = tpl.read_text() + assert "\\documentclass" in body and "\\includegraphics" in body + + +def test_report_writer_toolset_excludes_scripts(): + skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report_writer"]) + names = {t.name for t in make_skill_toolset(skills)._tools} + assert "run_skill_script" not in names + assert {"list_skills", "load_skill", "load_skill_resource"} <= names From e3c42c97fffacb6e11452b68a3a1ebb2e89c4fab Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:15:00 -0400 Subject: [PATCH 5/8] fix(research_demo): rename skill to kebab-case report-writer (ADK default; drop experimental snake-case flag) Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- .../skills/{report_writer => report-writer}/SKILL.md | 0 .../assets/report_template.tex | 0 tests/examples/conftest.py | 11 ----------- tests/examples/test_report_writer_skill.py | 8 ++++---- 4 files changed, 4 insertions(+), 15 deletions(-) rename examples/research_demo/skills/{report_writer => report-writer}/SKILL.md (100%) rename examples/research_demo/skills/{report_writer => report-writer}/assets/report_template.tex (100%) delete mode 100644 tests/examples/conftest.py diff --git a/examples/research_demo/skills/report_writer/SKILL.md b/examples/research_demo/skills/report-writer/SKILL.md similarity index 100% rename from examples/research_demo/skills/report_writer/SKILL.md rename to examples/research_demo/skills/report-writer/SKILL.md diff --git a/examples/research_demo/skills/report_writer/assets/report_template.tex b/examples/research_demo/skills/report-writer/assets/report_template.tex similarity index 100% rename from examples/research_demo/skills/report_writer/assets/report_template.tex rename to examples/research_demo/skills/report-writer/assets/report_template.tex diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py deleted file mode 100644 index 47a8bd7..0000000 --- a/tests/examples/conftest.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Shared fixtures for tests/examples. - -Sets ADK feature flags required for the examples test suite. -""" - -import os - -# ADK >= 1.36 requires SNAKE_CASE_SKILL_NAME feature flag to accept -# underscore-style skill names (e.g. report_writer). Set before any test -# imports google.adk so the flag is seen by every call to is_feature_enabled. -os.environ.setdefault("ADK_ENABLE_SNAKE_CASE_SKILL_NAME", "1") diff --git a/tests/examples/test_report_writer_skill.py b/tests/examples/test_report_writer_skill.py index 9349cd6..ad9fb01 100644 --- a/tests/examples/test_report_writer_skill.py +++ b/tests/examples/test_report_writer_skill.py @@ -13,21 +13,21 @@ def test_report_writer_skill_resolves(): - skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report_writer"]) + skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report-writer"]) assert len(skills) == 1 - assert skills[0].name == "report_writer" + assert skills[0].name == "report-writer" assert skills[0].description # non-empty when-to-use def test_report_writer_template_asset_present(): - tpl = _SKILLS_DIR / "report_writer" / "assets" / "report_template.tex" + tpl = _SKILLS_DIR / "report-writer" / "assets" / "report_template.tex" assert tpl.is_file() body = tpl.read_text() assert "\\documentclass" in body and "\\includegraphics" in body def test_report_writer_toolset_excludes_scripts(): - skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report_writer"]) + skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report-writer"]) names = {t.name for t in make_skill_toolset(skills)._tools} assert "run_skill_script" not in names assert {"list_skills", "load_skill", "load_skill_resource"} <= names From 145d05b15b1aced15992ab3d7ff8047911cbf125 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:21:53 -0400 Subject: [PATCH 6/8] =?UTF-8?q?fix(research=5Fdemo):=20complete=20kebab=20?= =?UTF-8?q?rename=20=E2=80=94=20SKILL.md=20name/heading/resource=20ref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- examples/research_demo/skills/report-writer/SKILL.md | 6 +++--- tests/examples/test_report_writer_skill.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/research_demo/skills/report-writer/SKILL.md b/examples/research_demo/skills/report-writer/SKILL.md index c51dc3b..c84820c 100644 --- a/examples/research_demo/skills/report-writer/SKILL.md +++ b/examples/research_demo/skills/report-writer/SKILL.md @@ -1,9 +1,9 @@ --- -name: report_writer +name: report-writer description: Write and compile a LaTeX analysis report to PDF from figures and tables already produced in the run's artifacts directory. Use when the user wants a written or PDF report of a completed analysis. --- -# report_writer +# report-writer Produce a compiled PDF analysis report with LaTeX. You author a `.tex` file and compile it with the `compile_document` tool. You do NOT run arbitrary code. @@ -31,7 +31,7 @@ Write these sections, in order: ## Authoring -1. Load the template: `load_skill_resource("report_writer", "assets/report_template.tex")`. +1. Load the template: `load_skill_resource("report-writer", "assets/report_template.tex")`. 2. Fill the placeholders (title, author/date, section prose, figure includes, table rows). 3. Escape LaTeX specials in prose: `% & _ # $ { }` → `\% \& \_ \# \$ \{ \}`. 4. `write_file` the filled source to the build directory as `report.tex`. diff --git a/tests/examples/test_report_writer_skill.py b/tests/examples/test_report_writer_skill.py index ad9fb01..359f44e 100644 --- a/tests/examples/test_report_writer_skill.py +++ b/tests/examples/test_report_writer_skill.py @@ -1,4 +1,4 @@ -"""Resolution + asset tests for the demo report_writer skill (no LLM).""" +"""Resolution + asset tests for the demo report-writer skill (no LLM).""" from __future__ import annotations from pathlib import Path @@ -12,21 +12,21 @@ _SKILLS_DIR = Path(__file__).resolve().parents[2] / "examples" / "research_demo" / "skills" -def test_report_writer_skill_resolves(): +def test_skill_resolves(): skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report-writer"]) assert len(skills) == 1 assert skills[0].name == "report-writer" assert skills[0].description # non-empty when-to-use -def test_report_writer_template_asset_present(): +def test_template_asset_present(): tpl = _SKILLS_DIR / "report-writer" / "assets" / "report_template.tex" assert tpl.is_file() body = tpl.read_text() assert "\\documentclass" in body and "\\includegraphics" in body -def test_report_writer_toolset_excludes_scripts(): +def test_toolset_excludes_scripts(): skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report-writer"]) names = {t.name for t in make_skill_toolset(skills)._tools} assert "run_skill_script" not in names From 0e32a1cd59998bafb9370e51ea84e3f80471e5e5 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:26:16 -0400 Subject: [PATCH 7/8] feat(research_demo): wire report_writer sub-agent (compile_document + skill) Adds report_writer leaf (write_file/read_file/glob/compile_document + skill), coordinator delegation, and default skills_dirs. No sandbox_execute on it. Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- examples/research_demo/agents.py | 54 +++++++++++++++++++-- examples/research_demo/settings.py | 4 ++ tests/examples/test_research_demo_agents.py | 21 ++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/examples/research_demo/agents.py b/examples/research_demo/agents.py index c78689e..a476e7d 100644 --- a/examples/research_demo/agents.py +++ b/examples/research_demo/agents.py @@ -1,14 +1,17 @@ """Agent configuration for the Research Demo application. -Multi-agent architecture with three agents: +Multi-agent architecture with four agents: - research_coordinator: Root agent that owns workflow state (planning, tasks, HITL) - and delegates to the arXiv specialist and data analyst. + and delegates to the arXiv specialist, data analyst, and report writer. - arxiv_specialist: Leaf agent focused on arXiv paper search, analysis, and ingestion. - data_analyst: Leaf agent that runs multi-step data analysis in a stateful executor. +- report_writer: Leaf agent that turns analysis artifacts into a compiled LaTeX PDF. Uses framework-provided tools exclusively — no app-specific tools needed. """ +from pathlib import Path + from agentic_cli.workflow import AgentConfig from agentic_cli.tools import ( memory_tools, @@ -26,6 +29,7 @@ diff_compare, grep, glob, + compile_document, ) from agentic_cli.tools.sandbox import sandbox_execute @@ -125,6 +129,40 @@ """ +# --------------------------------------------------------------------------- +# Report Writer (leaf agent) +# --------------------------------------------------------------------------- + +def report_writer_prompt() -> str: + """Build the prompt from the *active* settings, so the artifacts/build paths + follow whatever workspace is configured (default ~/.research_demo, a tmp dir + under test, or a user override) instead of a hard-coded path. AgentConfig + calls this (get_prompt()) at agent-assembly time, when settings are set.""" + from agentic_cli.config import get_settings + + settings = get_settings() + artifacts_dir = str( + settings.sandbox_outputs_dir or (Path(settings.workspace_dir) / "artifacts") + ) + reports_dir = str(Path(settings.workspace_dir) / "reports") + return f"""You are a report writer. You turn a completed data analysis into a compiled PDF report using LaTeX. + +## Where things are +- Analysis deliverables (figures as .png, tables as .csv) are in the artifacts directory: {artifacts_dir} +- Author your LaTeX source in the build directory: {reports_dir} +- Deliver the final PDF to: {artifacts_dir}/report.pdf + +## How to work +You have a `report_writer` skill — call `load_skill("report-writer")` for the report structure, the LaTeX template, the compile steps, and the error-recovery guide. In short: +1. `glob("{artifacts_dir}/*.png")` to learn the exact figure filenames. +2. Load the template, fill it in, and `write_file` it to {reports_dir}/report.tex (reference figures by BARE filename). +3. Compile with `compile_document(source_path="{reports_dir}/report.tex", output_pdf="{artifacts_dir}/report.pdf", assets_dir="{artifacts_dir}")`. +4. If it fails, read the returned errors/log_tail, fix the .tex, and recompile (at most 3 tries). + +Report the final PDF path to the user. You only use these tools — you do not run arbitrary code. +""" + + # --------------------------------------------------------------------------- # Research Coordinator (root agent) # --------------------------------------------------------------------------- @@ -178,6 +216,7 @@ 7. **WAIT for user confirmation** before executing tasks. 8. For arXiv paper research, **delegate to arxiv_specialist** (it has KB writer access and writes concept pages when 3+ related papers accumulate). - For multi-step data analysis (datasets, DataFrames, plots), delegate to **data_analyst**. Use `execute_python` only for quick one-off calculations. +- To produce a written/PDF **report** of a completed analysis, delegate to **report_writer** (it compiles a LaTeX report from the analysis artifacts). 9. Execute ONE task at a time, updating the plan after each. 10. Use `web_fetch` to extract information from specific URLs found during research. 11. Use `execute_python` for quick calculations and data validation. @@ -248,6 +287,15 @@ tools=[sandbox_execute, read_file, write_file, ask_clarification], description="Stateful data-analysis specialist: loads datasets and runs multi-step pandas/plotting analysis in an isolated executor.", ), + # Leaf agent: report writer (must be listed before coordinator) + AgentConfig( + name="report_writer", + prompt=report_writer_prompt, + include_state_tools=False, + tools=[write_file, read_file, glob, compile_document, ask_clarification], + skills=["report-writer"], + description="Report writer: turns the analysis figures/tables in the artifacts dir into a compiled LaTeX PDF report.", + ), # Root agent: research coordinator (owns workflow state, delegates arXiv work) AgentConfig( name="research_coordinator", @@ -273,7 +321,7 @@ grep, diff_compare, ], - sub_agents=["arxiv_specialist", "data_analyst"], + sub_agents=["arxiv_specialist", "data_analyst", "report_writer"], description="Research coordinator with memory, planning, task management, knowledge base, and HITL capabilities", ), ] diff --git a/examples/research_demo/settings.py b/examples/research_demo/settings.py index 12b922c..9f58c4d 100644 --- a/examples/research_demo/settings.py +++ b/examples/research_demo/settings.py @@ -47,3 +47,7 @@ def model_post_init(self, __context): object.__setattr__(self, "sandbox_data_mounts", [f"{data_dir}:samples"]) if "sandbox_outputs_dir" not in self.model_fields_set: object.__setattr__(self, "sandbox_outputs_dir", str(Path(self.workspace_dir) / "artifacts")) + if "skills_dirs" not in self.model_fields_set: + object.__setattr__( + self, "skills_dirs", [str(Path(__file__).parent / "skills")] + ) diff --git a/tests/examples/test_research_demo_agents.py b/tests/examples/test_research_demo_agents.py index 534f4e4..abaa677 100644 --- a/tests/examples/test_research_demo_agents.py +++ b/tests/examples/test_research_demo_agents.py @@ -14,3 +14,24 @@ def test_data_analyst_wired(): assert "sandbox_execute" in tool_names coord = next(a for a in AGENT_CONFIGS if a.name == "research_coordinator") assert "data_analyst" in coord.sub_agents + + +def test_report_writer_wired(): + from research_demo.agents import AGENT_CONFIGS + + names = {a.name for a in AGENT_CONFIGS} + assert "report_writer" in names + rw = next(a for a in AGENT_CONFIGS if a.name == "report_writer") + tool_names = {t.__name__ for t in rw.tools} + assert "compile_document" in tool_names + assert "sandbox_execute" not in tool_names # no arbitrary code exec + assert rw.skills == ["report-writer"] + coord = next(a for a in AGENT_CONFIGS if a.name == "research_coordinator") + assert "report_writer" in coord.sub_agents + + +def test_report_writer_skills_dir_configured(): + from research_demo.settings import ResearchDemoSettings + + s = ResearchDemoSettings() + assert any(str(p).rstrip("/").endswith("skills") for p in s.skills_dirs) From 5f66ee48b7ff67ef2a2d5b9afb2a611313b0f05a Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:44:37 -0400 Subject: [PATCH 8/8] fix(tools,research_demo): process-group timeout kill; scope shell-escape claim to pdflatex; log-read never-raise; kebab prompt prose Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- examples/research_demo/agents.py | 2 +- src/agentic_cli/tools/document/compile.py | 48 ++++++++++++++++------- tests/tools/test_document_compile.py | 34 ++++++++++++++++ 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/examples/research_demo/agents.py b/examples/research_demo/agents.py index a476e7d..765173c 100644 --- a/examples/research_demo/agents.py +++ b/examples/research_demo/agents.py @@ -153,7 +153,7 @@ def report_writer_prompt() -> str: - Deliver the final PDF to: {artifacts_dir}/report.pdf ## How to work -You have a `report_writer` skill — call `load_skill("report-writer")` for the report structure, the LaTeX template, the compile steps, and the error-recovery guide. In short: +You have a `report-writer` skill — call `load_skill("report-writer")` for the report structure, the LaTeX template, the compile steps, and the error-recovery guide. In short: 1. `glob("{artifacts_dir}/*.png")` to learn the exact figure filenames. 2. Load the template, fill it in, and `write_file` it to {reports_dir}/report.tex (reference figures by BARE filename). 3. Compile with `compile_document(source_path="{reports_dir}/report.tex", output_pdf="{artifacts_dir}/report.pdf", assets_dir="{artifacts_dir}")`. diff --git a/src/agentic_cli/tools/document/compile.py b/src/agentic_cli/tools/document/compile.py index 30061b9..6a86788 100644 --- a/src/agentic_cli/tools/document/compile.py +++ b/src/agentic_cli/tools/document/compile.py @@ -1,10 +1,16 @@ """Compile a LaTeX document to PDF with a host TeX engine. ``compile_document`` is a narrow, permission-gated tool: it runs ``latexmk`` -(preferred) or ``pdflatex`` as a guarded subprocess — no shell-escape, a -wall-clock timeout, and a scoped working dir — and returns a structured result. -It does NOT execute arbitrary code; a ``report_writer``-style agent uses it to -turn an authored ``.tex`` into a PDF. +(preferred) or ``pdflatex`` as a guarded subprocess — shell-escape is disabled +for the **pdflatex** path (``-no-shell-escape`` flag). For ``latexmk``, the +engine relies on its default restricted mode; note that ``latexmk`` also reads +``.latexmkrc`` (arbitrary Perl) from the build directory and home directory, so +callers with an untrusted ``.tex``/build directory should prefer ``pdflatex``. +OS-sandbox confinement for the general case is deferred. + +The tool runs with a wall-clock timeout and a scoped working dir, and returns a +structured result. It does NOT execute arbitrary code; a ``report_writer``-style +agent uses it to turn an authored ``.tex`` into a PDF. Provisioning is host-based: the engine must be on ``PATH`` (TeX Live / MacTeX). If neither is found the tool returns a structured error with an install hint. @@ -18,6 +24,7 @@ import os import shutil +import signal import subprocess import time from pathlib import Path @@ -38,10 +45,21 @@ def _which(name: str) -> str | None: def _run( argv: list[str], *, cwd: str, env: dict[str, str], timeout: float ) -> subprocess.CompletedProcess: - """Run a subprocess capturing output (seam for tests).""" - return subprocess.run( - argv, cwd=cwd, env=env, capture_output=True, text=True, timeout=timeout + """Run a subprocess in its own process group so a timeout kills the whole + tree (latexmk + its pdflatex grandchild), not just the direct child. Seam + for tests. POSIX (macOS/Linux), which is what the framework targets.""" + proc = subprocess.Popen( + argv, cwd=cwd, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + start_new_session=True, ) + try: + out, err = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + proc.communicate() # reap the killed group + raise + return subprocess.CompletedProcess(argv, proc.returncode, stdout=out, stderr=err) def _detect_engine(engine: str | None) -> str | None: @@ -74,8 +92,10 @@ def _parse_errors(log_text: str) -> list[str]: capabilities=[Capability("document.compile", target_arg="source_path")], description=( "Compile a LaTeX source file to PDF using a host TeX engine (latexmk or " - "pdflatex), with shell-escape disabled. Returns the PDF path plus any " - "compiler errors. Requires TeX Live/MacTeX on PATH." + "pdflatex). Shell-escape is disabled for pdflatex (-no-shell-escape); " + "latexmk uses its default restricted mode but also reads .latexmkrc from " + "the build/home directory. Returns the PDF path plus any compiler errors. " + "Requires TeX Live/MacTeX on PATH." ), ) def compile_document( @@ -139,7 +159,7 @@ def compile_document( "pdf_path": None, "engine": chosen, "log_tail": "", "errors": [], "duration_ms": int((time.monotonic() - start) * 1000), } - except (FileNotFoundError, OSError) as exc: + except OSError as exc: return { "success": False, "error": f"Failed to run {chosen}: {exc}", "pdf_path": None, "engine": chosen, "log_tail": "", "errors": [], @@ -148,10 +168,10 @@ def compile_document( duration_ms = int((time.monotonic() - start) * 1000) log_path = work_dir / (src.stem + ".log") - log_text = ( - log_path.read_text(errors="replace") if log_path.is_file() - else (proc.stdout or "") - ) + try: + log_text = log_path.read_text(errors="replace") if log_path.is_file() else (proc.stdout or "") + except OSError: + log_text = proc.stdout or "" log_tail = "\n".join(log_text.splitlines()[-_LOG_TAIL_LINES:]) produced = work_dir / (src.stem + ".pdf") success = proc.returncode == 0 and produced.is_file() diff --git a/tests/tools/test_document_compile.py b/tests/tools/test_document_compile.py index 487bdef..1a66369 100644 --- a/tests/tools/test_document_compile.py +++ b/tests/tools/test_document_compile.py @@ -155,3 +155,37 @@ def test_forced_unsupported_engine_rejected(monkeypatch, tmp_path): r = compile_document(str(tex), engine="xelatex") assert r["success"] is False assert "No LaTeX engine" in r["error"] + + +# --- Fix wave — final review (I1/I2/M1/M2/M3) --- + +def test_run_group_kill_on_timeout(monkeypatch): + """I1: _run must start a new session and kill the process group on timeout.""" + import subprocess as _subprocess + + popen_kwargs: dict = {} + killpg_calls: list = [] + + class FakePopen: + pid = 42 + + def __init__(self, argv, **kwargs): + popen_kwargs.update(kwargs) + + def communicate(self, timeout=None): + if timeout is not None: + raise _subprocess.TimeoutExpired([], timeout) + # reap call after kill + return ("", "") + + monkeypatch.setattr(mod.subprocess, "Popen", FakePopen) + monkeypatch.setattr(mod.os, "getpgid", lambda pid: pid) + monkeypatch.setattr(mod.os, "killpg", lambda pgid, sig: killpg_calls.append((pgid, sig))) + + try: + mod._run(["latexmk"], cwd="/tmp", env={}, timeout=1.0) + except _subprocess.TimeoutExpired: + pass # expected + + assert popen_kwargs.get("start_new_session") is True, "Popen must use start_new_session=True" + assert len(killpg_calls) >= 1, "os.killpg must be called on timeout"