Skip to content

Commit 82802c1

Browse files
committed
Split test_agent.py into tests/agent package
1 parent 36a6632 commit 82802c1

10 files changed

Lines changed: 1011 additions & 890 deletions

‎tests/agent/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Agent test package: test_agent_*.py modules and agent_test_utils.
2+
3+
Makes the directory a package so ``unittest discover`` recurses into it.
4+
"""

‎tests/agent/agent_test_utils.py‎

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""End-to-end agent loop tests with a fake client and fake session."""
2+
3+
import json
4+
import os
5+
import sys
6+
import threading
7+
import time
8+
9+
sys.path.insert(0, os.path.dirname(__file__))
10+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) # tests/ for plan_cleanup
11+
12+
import plan_cleanup # noqa: F401,E402 (side-effect: auto-remove /tmp plan dirs)
13+
14+
from python_agent_harness.models import Message, ToolCall, Usage
15+
from python_agent_harness.persistence import SessionPersistence
16+
from python_agent_harness.session import Session
17+
from python_agent_harness.tools import default_registry
18+
19+
20+
class FakeClient:
21+
"""Scripted chat responses: (assistant_text, tool_calls) per call."""
22+
23+
def __init__(self, script):
24+
self.script = list(script)
25+
self.calls = []
26+
self.kwargs = []
27+
self.base_url = "https://fake.example/v1"
28+
self.api_key = None
29+
self.model = "gpt-5-mini"
30+
self.timeout = 600.0
31+
32+
def set_timeout(self, timeout):
33+
self.timeout = timeout
34+
35+
def chat(
36+
self,
37+
messages,
38+
tools=None,
39+
system=None,
40+
temperature=None,
41+
max_tokens=None,
42+
reasoning_effort=None,
43+
on_delta=None,
44+
stream=True,
45+
cancel_check=None,
46+
on_retry=None,
47+
):
48+
self.calls.append([m.to_api() for m in messages])
49+
self.kwargs.append(
50+
{
51+
"tools": tools,
52+
"system": system,
53+
"temperature": temperature,
54+
"max_tokens": max_tokens,
55+
"reasoning_effort": reasoning_effort,
56+
"stream": stream,
57+
"cancel_check": cancel_check,
58+
}
59+
)
60+
if not self.script:
61+
return Message(role="assistant", content="done"), Usage()
62+
item = self.script.pop(0)
63+
if isinstance(item, tuple):
64+
text, tool_calls = item
65+
else:
66+
text, tool_calls = item, None
67+
return Message(role="assistant", content=text, tool_calls=tool_calls), Usage(
68+
input_tokens=100
69+
)
70+
71+
def chat_sync(
72+
self,
73+
messages,
74+
system=None,
75+
temperature=None,
76+
max_tokens=None,
77+
reasoning_effort=None,
78+
cancel_check=None,
79+
):
80+
return Message(role="assistant", content="SYNC-OK"), Usage()
81+
82+
83+
class RecordingSession(Session):
84+
_test_session_dir: str | None = None
85+
86+
def __init__(self, project_dir="/tmp/fakeproj", model_profiles=None, llm_settings=None):
87+
if RecordingSession._test_session_dir is None:
88+
import tempfile as _tf
89+
90+
RecordingSession._test_session_dir = _tf.mkdtemp(prefix="pah-test-sessions-")
91+
import python_agent_harness.config as cfg
92+
93+
cfg.SESSION_DIR = __import__("pathlib").Path(RecordingSession._test_session_dir)
94+
super().__init__(
95+
project_dir=project_dir,
96+
client=FakeClient([]),
97+
model="gpt-5-mini",
98+
registry=default_registry(),
99+
model_profiles=model_profiles or {},
100+
llm_settings=llm_settings,
101+
)
102+
self.executed = []
103+
self.store = SessionPersistence(
104+
project_dir=project_dir,
105+
model=self.model,
106+
backend=self.backend,
107+
system_prompt=self.system_prompt,
108+
temperature=self.temperature,
109+
max_tokens=self.max_tokens,
110+
tool_names=self.store.tool_names,
111+
)
112+
113+
def execute_tool(self, name, args, call_id=None):
114+
self.executed.append((name, args))
115+
if name == "Read":
116+
return "file content"
117+
if name == "Bash":
118+
return "bash output"
119+
return f"result of {name}"
120+
121+
122+
class ParallelToolSession(RecordingSession):
123+
"""Session whose tool calls block (DURATION seconds, or until cancel
124+
when DURATION is None) while tracking peak concurrency across ALL
125+
tools."""
126+
127+
def __init__(self, duration=0.4):
128+
super().__init__()
129+
self.duration = duration
130+
self.active = 0
131+
self.max_active = 0
132+
self.executed_count = 0
133+
self._lock = threading.Lock()
134+
self.started = threading.Event()
135+
136+
def execute_tool(self, name, args, call_id=None):
137+
with self._lock:
138+
self.active += 1
139+
self.max_active = max(self.max_active, self.active)
140+
self.executed_count += 1
141+
self.started.set()
142+
try:
143+
if self.duration is None:
144+
deadline = time.monotonic() + 30
145+
while not self.cancel_event.is_set() and time.monotonic() < deadline:
146+
time.sleep(0.02)
147+
else:
148+
time.sleep(self.duration)
149+
if name == "Agent":
150+
return f"done:{args.get('description', 'task')}"
151+
return f"result of {name}"
152+
finally:
153+
with self._lock:
154+
self.active -= 1
155+
156+
157+
class SerialPromptSession(RecordingSession):
158+
"""Session whose interactive prompt handlers (Question / PlanExit
159+
confirm) each block DURATION seconds while tracking peak
160+
concurrency — to verify the session serializes them."""
161+
162+
def __init__(self, duration=0.3):
163+
super().__init__()
164+
self.duration = duration
165+
self.active = 0
166+
self.max_active = 0
167+
self._lock = threading.Lock()
168+
self.ask_fn = self._prompt
169+
self.confirm_fn = self._prompt
170+
171+
def _prompt(self, prompt):
172+
with self._lock:
173+
self.active += 1
174+
self.max_active = max(self.max_active, self.active)
175+
try:
176+
time.sleep(self.duration)
177+
return "yes"
178+
finally:
179+
with self._lock:
180+
self.active -= 1
181+
182+
183+
class StaggeredSession(RecordingSession):
184+
"""Session with per-tool durations that records the real execution
185+
completion order — so a test can prove the round delivers results
186+
in ORIGINAL call order even when execution finishes in a different
187+
order."""
188+
189+
def __init__(self, durations):
190+
super().__init__()
191+
self.durations = durations
192+
self.active = 0
193+
self.max_active = 0
194+
self.completed = []
195+
self._lock = threading.Lock()
196+
197+
def execute_tool(self, name, args, call_id=None):
198+
with self._lock:
199+
self.active += 1
200+
self.max_active = max(self.max_active, self.active)
201+
try:
202+
time.sleep(self.durations[name])
203+
return f"result of {name}"
204+
finally:
205+
with self._lock:
206+
self.active -= 1
207+
self.completed.append(name)
208+
209+
210+
class RealParallelSession(RecordingSession):
211+
"""Session that runs REAL sub-agents (the Agent tool delegates to
212+
the real Session.execute_tool) while every other tool blocks
213+
DURATION seconds, tracking concurrency across the parent round and
214+
the sub-agent's own round alike (a sub-agent shares this session)."""
215+
216+
def __init__(self, duration=0.4):
217+
super().__init__()
218+
self.duration = duration
219+
self.active = 0
220+
self.max_active = 0
221+
self.executed_names = []
222+
self._lock = threading.Lock()
223+
224+
def execute_tool(self, name, args, call_id=None):
225+
if name == "Agent":
226+
return Session.execute_tool(self, name, args, call_id=call_id)
227+
with self._lock:
228+
self.active += 1
229+
self.max_active = max(self.max_active, self.active)
230+
self.executed_names.append(name)
231+
try:
232+
time.sleep(self.duration)
233+
return f"result of {name}"
234+
finally:
235+
with self._lock:
236+
self.active -= 1
237+
238+
239+
def agent_call(call_id, description, prompt="do it"):
240+
return ToolCall(
241+
id=call_id,
242+
name="Agent",
243+
arguments=json.dumps(
244+
{
245+
"subagent_type": "subagent",
246+
"description": description,
247+
"prompt": prompt,
248+
}
249+
),
250+
)

0 commit comments

Comments
 (0)