Skip to content

Commit 1c487a2

Browse files
committed
test(templates): run the Claude Code scaffold against a real child process
Render the sync Claude Code template, put a fake `claude` on PATH that prints a 100,000-character stream-json line and ignores SIGTERM, and drive _spawn_claude(): the long line must arrive whole and aclose() must return once the (shortened) grace period ends. Against main's template the test fails with "Separator is found, but chunk is longer than limit" after the unbounded wait sits out the child's 60 s sleep; here it passes in under 4 s.
1 parent a35652a commit 1c487a2

1 file changed

Lines changed: 88 additions & 0 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""The rendered Claude Code scaffold against a real child process.
2+
3+
A fake ``claude`` on PATH stands in for the CLI, so these run offline but
4+
exercise the template's actual subprocess handling: the stdout line limit
5+
and the SIGTERM-then-SIGKILL shutdown.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import sys
11+
import time
12+
import types
13+
import asyncio
14+
import importlib.util
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
from agentex.lib.core.tracing import tracing_processor_manager
20+
from agentex.lib.cli.commands.init import TemplateType, get_project_context, create_project_structure
21+
22+
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="needs POSIX signals")
23+
24+
LONG_LINE_CHARS = 100_000
25+
FAST_TERMINATE_SECONDS = 0.5
26+
SHUTDOWN_BUDGET_SECONDS = 10.0
27+
28+
FAKE_CLAUDE = """#!{python}
29+
import json, signal, sys, time
30+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
31+
sys.stdin.read()
32+
print(json.dumps({{"type": "system", "subtype": "init", "session_id": "s"}}), flush=True)
33+
print(json.dumps({{"type": "user", "message": {{"content": [{{"type": "tool_result", "content": "x" * {chars}}}]}}}}), flush=True)
34+
time.sleep(60)
35+
"""
36+
37+
38+
def _render_sync_template(tmp_path: Path) -> Path:
39+
template = TemplateType.SYNC_CLAUDE_CODE
40+
answers = {
41+
"template_type": template,
42+
"project_path": str(tmp_path),
43+
"agent_name": "cc-bot",
44+
"agent_directory_name": "cc-bot",
45+
"description": "test",
46+
"use_uv": True,
47+
}
48+
context = get_project_context(answers, tmp_path, Path("../../"))
49+
context["template_type"] = template.value
50+
context["use_uv"] = True
51+
create_project_structure(tmp_path, context, template, True)
52+
return tmp_path / "cc_bot" / "project" / "acp.py"
53+
54+
55+
@pytest.fixture
56+
def scaffold(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
57+
bin_dir = tmp_path / "bin"
58+
bin_dir.mkdir()
59+
fake = bin_dir / "claude"
60+
fake.write_text(FAKE_CLAUDE.format(python=sys.executable, chars=LONG_LINE_CHARS))
61+
fake.chmod(0o755)
62+
monkeypatch.setenv("PATH", f"{bin_dir}:{Path(sys.executable).parent}")
63+
monkeypatch.setenv("AGENT_NAME", "cc-bot")
64+
monkeypatch.setenv("ACP_URL", "http://localhost:8000")
65+
monkeypatch.setattr(tracing_processor_manager, "add_tracing_processor_config", lambda _config: None)
66+
67+
spec = importlib.util.spec_from_file_location("cc_bot_acp", _render_sync_template(tmp_path))
68+
assert spec is not None and spec.loader is not None
69+
module = importlib.util.module_from_spec(spec)
70+
spec.loader.exec_module(module)
71+
monkeypatch.setattr(module, "TERMINATE_TIMEOUT_SECONDS", FAST_TERMINATE_SECONDS, raising=False)
72+
return module
73+
74+
75+
async def test_long_stream_json_line_is_read_whole_and_shutdown_is_bounded(scaffold: types.ModuleType) -> None:
76+
"""A tool_result past asyncio's 64 KiB default arrives intact, and a CLI that
77+
ignores SIGTERM is killed after the grace period instead of hanging cleanup."""
78+
lines = scaffold._spawn_claude("hi")
79+
first = await lines.__anext__()
80+
second = await lines.__anext__()
81+
82+
started = time.monotonic()
83+
await asyncio.wait_for(lines.aclose(), SHUTDOWN_BUDGET_SECONDS)
84+
elapsed = time.monotonic() - started
85+
86+
assert '"init"' in first
87+
assert second.count("x") == LONG_LINE_CHARS
88+
assert elapsed >= FAST_TERMINATE_SECONDS

0 commit comments

Comments
 (0)