main
py 443 lines 14.4 KB
Raw
1 from __future__ import annotations
2
3 from datetime import datetime, timezone
4 from types import SimpleNamespace
5
6 import pytest
7
8 from agent import Agent, AgentConfig, AgentContext
9 from helpers import persist_chat, projects, settings
10 from helpers.errors import RepairableException
11
12
13 class _FakeContext:
14 def __init__(self, id: str = "ctx") -> None:
15 self.id = id
16 self.name = None
17 self.data = {}
18 self.output_data = {}
19 self.created_at = datetime.now(timezone.utc)
20 self.agent0 = None
21
22 def get_data(self, key: str, recursive: bool = True):
23 return self.data.get(key)
24
25 def set_data(self, key: str, value, recursive: bool = True):
26 self.data[key] = value
27
28 def get_output_data(self, key: str, recursive: bool = True):
29 return self.output_data.get(key)
30
31 def set_output_data(self, key: str, value, recursive: bool = True):
32 self.output_data[key] = value
33
34 def is_running(self) -> bool:
35 return False
36
37
38 class _FakeParentAgent:
39 def __init__(self) -> None:
40 self.number = 0
41 self.agent_name = "A0"
42 self.config = AgentConfig(mcp_servers="", profile="agent0")
43 self.context = _FakeContext()
44 self.data = {}
45
46 def get_data(self, key: str):
47 return self.data.get(key)
48
49 def set_data(self, key: str, value):
50 self.data[key] = value
51
52 def read_prompt(self, _file: str, **_kwargs) -> str:
53 return ""
54
55
56 def test_hidden_default_profile_normalizes_to_agent0() -> None:
57 configured = settings.get_default_settings()
58 configured["agent_profile"] = "default"
59
60 assert settings.normalize_settings(configured)["agent_profile"] == "agent0"
61
62
63 class _FakeSubAgent:
64 DATA_NAME_SUPERIOR = "_superior"
65 DATA_NAME_SUBORDINATE = "_subordinate"
66
67 _counter = 0
68
69 def __init__(self, number: int, config: AgentConfig, context=None) -> None:
70 if context is None:
71 self.__class__._counter += 1
72 context = _FakeContext(f"child-{self.__class__._counter}")
73 self.number = number
74 self.agent_name = f"A{number}"
75 self.config = config
76 self.context = context
77 self.context.agent0 = self
78 self.data = {}
79 self.history = SimpleNamespace(new_topic=lambda: None)
80 self.messages = []
81
82 def set_data(self, key: str, value):
83 self.data[key] = value
84
85 def get_data(self, key: str):
86 return self.data.get(key)
87
88 def hist_add_user_message(self, message):
89 self.messages.append(message)
90
91 async def monologue(self):
92 return "delegated"
93
94
95 @pytest.mark.asyncio
96 async def test_call_subordinate_rejects_unknown_profile(monkeypatch) -> None:
97 import tools.call_subordinate as call_subordinate
98
99 monkeypatch.setattr(
100 call_subordinate,
101 "_subordinate_profile_labels",
102 lambda _agent: {"developer": "Developer", "researcher": "Researcher"},
103 )
104 parent = _FakeParentAgent()
105 tool = call_subordinate.Delegation(
106 parent, # type: ignore[arg-type]
107 "call_subordinate",
108 None,
109 {"profile": "ghost", "message": "work"},
110 "",
111 None,
112 )
113
114 with pytest.raises(RepairableException, match="Agent profile 'ghost' not found"):
115 await tool.execute(message="work", profile="ghost", reset=True)
116
117 assert parent.data == {}
118
119
120 @pytest.mark.asyncio
121 async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
122 import tools.call_subordinate as call_subordinate
123
124 monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
125 monkeypatch.setattr(
126 call_subordinate,
127 "_subordinate_profile_labels",
128 lambda _agent: {"developer": "Developer"},
129 )
130 monkeypatch.setattr(
131 call_subordinate,
132 "initialize_agent",
133 lambda override_settings=None: AgentConfig(
134 mcp_servers="",
135 profile=(override_settings or {}).get("agent_profile", "agent0"),
136 ),
137 )
138 monkeypatch.setattr(
139 call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
140 )
141 monkeypatch.setattr(
142 call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
143 )
144
145 parent = _FakeParentAgent()
146 tool = call_subordinate.Delegation(
147 parent, # type: ignore[arg-type]
148 "call_subordinate",
149 None,
150 {"profile": "developer", "message": "work"},
151 "",
152 None,
153 )
154
155 response = await tool.execute(message="work", profile="developer", reset=True)
156 children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
157 child = next(iter(children.values()))
158
159 assert response.message == "delegated"
160 assert response.additional == {"context_id": child.context.id}
161 assert child.number == 1
162 assert child.config.profile == "developer"
163 assert child.messages[0].message == "work"
164
165
166 @pytest.mark.asyncio
167 async def test_call_subordinate_reset_false_reuses_numbered_child(monkeypatch) -> None:
168 import tools.call_subordinate as call_subordinate
169
170 monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
171 monkeypatch.setattr(
172 call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
173 )
174 monkeypatch.setattr(
175 call_subordinate,
176 "initialize_agent",
177 lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
178 )
179 monkeypatch.setattr(
180 call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
181 )
182 monkeypatch.setattr(
183 call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
184 )
185
186 parent = _FakeParentAgent()
187 tool = call_subordinate.Delegation(
188 parent, # type: ignore[arg-type]
189 "call_subordinate",
190 None,
191 {},
192 "",
193 None,
194 )
195 first = await tool.execute(message="first", reset=True)
196 second = await tool.execute(
197 message="continue",
198 context_id=first.additional["context_id"], # type: ignore[index]
199 reset=False,
200 )
201
202 children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
203 child = next(iter(children.values()))
204 assert len(children) == 1
205 assert child.number == 1
206 assert [message.message for message in child.messages] == ["first", "continue"]
207 assert second.additional == first.additional
208
209
210 def test_subordinate_tree_numbers_each_generation(monkeypatch) -> None:
211 import tools.call_subordinate as call_subordinate
212
213 monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
214 monkeypatch.setattr(
215 call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
216 )
217 monkeypatch.setattr(
218 call_subordinate,
219 "initialize_agent",
220 lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
221 )
222
223 parent = _FakeParentAgent()
224 child = call_subordinate.get_or_create_subordinate(
225 parent, # type: ignore[arg-type]
226 reset=True,
227 message="A1 work",
228 )
229 grandchild = call_subordinate.get_or_create_subordinate(
230 child, # type: ignore[arg-type]
231 reset=True,
232 message="A2 work",
233 )
234
235 assert child.number == 1
236 assert grandchild.number == 2
237 assert child.context.get_output_data("parent_context_id") == parent.context.id
238 assert grandchild.context.get_output_data("parent_context_id") == child.context.id
239 assert grandchild.get_data(Agent.DATA_NAME_SUPERIOR) is child
240
241
242 def test_subordinate_context_id_is_scoped_to_its_parent(monkeypatch) -> None:
243 import tools.call_subordinate as call_subordinate
244
245 monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
246 monkeypatch.setattr(
247 call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
248 )
249 monkeypatch.setattr(
250 call_subordinate,
251 "initialize_agent",
252 lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
253 )
254
255 owner = _FakeParentAgent()
256 other = _FakeParentAgent()
257 other.context = _FakeContext("other-parent")
258 child = call_subordinate.get_or_create_subordinate(
259 owner, # type: ignore[arg-type]
260 reset=True,
261 message="private branch",
262 )
263
264 with pytest.raises(RepairableException, match="was not found under A0"):
265 call_subordinate.get_or_create_subordinate(
266 other, # type: ignore[arg-type]
267 context_id=child.context.id,
268 reset=False,
269 )
270
271
272 @pytest.mark.asyncio
273 async def test_call_subordinate_requires_reset_to_change_existing_profile(monkeypatch) -> None:
274 import tools.call_subordinate as call_subordinate
275
276 monkeypatch.setattr(
277 call_subordinate,
278 "_subordinate_profile_labels",
279 lambda _agent: {"developer": "Developer", "researcher": "Researcher"},
280 )
281
282 parent = _FakeParentAgent()
283 existing = SimpleNamespace(config=AgentConfig(mcp_servers="", profile="developer"))
284 parent.set_data(_FakeSubAgent.DATA_NAME_SUBORDINATE, existing)
285 tool = call_subordinate.Delegation(
286 parent, # type: ignore[arg-type]
287 "call_subordinate",
288 None,
289 {"profile": "researcher", "message": "work"},
290 "",
291 None,
292 )
293
294 with pytest.raises(RepairableException, match="Set reset=true"):
295 await tool.execute(message="work", profile="researcher", reset=False)
296
297
298 def test_persist_chat_roundtrip_preserves_each_agent_profile(monkeypatch) -> None:
299 monkeypatch.setattr(
300 persist_chat,
301 "initialize_agent",
302 lambda override_settings=None: AgentConfig(
303 mcp_servers="",
304 profile=(override_settings or {}).get("agent_profile", "agent0"),
305 ),
306 )
307
308 context_id = "ctx-subagent-profile"
309 AgentContext.remove(context_id)
310 context = AgentContext(
311 config=AgentConfig(mcp_servers="", profile="agent0"),
312 id=context_id,
313 set_current=False,
314 )
315 child = Agent(1, AgentConfig(mcp_servers="", profile="developer"), context)
316 context.agent0.set_data(Agent.DATA_NAME_SUBORDINATE, child)
317 child.set_data(Agent.DATA_NAME_SUPERIOR, context.agent0)
318
319 try:
320 serialized = persist_chat._serialize_context(context)
321 assert serialized["agent_profile"] == "agent0"
322 assert serialized["agents"][0]["agent_profile"] == "agent0"
323 assert serialized["agents"][1]["agent_profile"] == "developer"
324
325 AgentContext.remove(context_id)
326 restored = persist_chat._deserialize_context(serialized)
327 restored_child = restored.agent0.get_data(Agent.DATA_NAME_SUBORDINATE)
328
329 assert restored.config.profile == "agent0"
330 assert restored.agent0.config.profile == "agent0"
331 assert restored_child.config.profile == "developer"
332 finally:
333 AgentContext.remove(context_id)
334
335
336 def test_persisted_numbered_child_is_reusable_after_reload(monkeypatch) -> None:
337 import tools.call_subordinate as call_subordinate
338
339 config_factory = lambda override_settings=None: AgentConfig(
340 mcp_servers="",
341 profile=(override_settings or {}).get("agent_profile", "agent0"),
342 )
343 monkeypatch.setattr(
344 persist_chat,
345 "initialize_agent",
346 config_factory,
347 )
348 monkeypatch.setattr(call_subordinate, "initialize_agent", config_factory)
349
350 parent_id = "ctx-persisted-agent-tree-parent"
351 AgentContext.remove(parent_id)
352 parent = AgentContext(
353 AgentConfig(mcp_servers="", profile="agent0"),
354 id=parent_id,
355 set_current=False,
356 )
357 child = call_subordinate.get_or_create_subordinate(
358 parent.agent0,
359 reset=True,
360 message="persist me",
361 )
362 context_id = child.context.id
363 try:
364 assert len(persist_chat._serialize_context(parent)["agents"]) == 1
365 serialized = persist_chat._serialize_context(child.context)
366 AgentContext.remove(context_id)
367 parent.agent0.data.pop(call_subordinate.SUBORDINATES_DATA_KEY, None)
368 restored = persist_chat._deserialize_context(serialized)
369 resumed = call_subordinate.get_or_create_subordinate(
370 parent.agent0,
371 context_id=context_id,
372 reset=False,
373 )
374
375 assert restored.agent0.number == 1
376 assert restored.agent0.agent_name == "A1"
377 assert restored.get_output_data("parent_context_id") == parent.id
378 assert restored.get_output_data("parent_agent_number") == 0
379 assert resumed is restored.agent0
380 assert resumed.get_data(Agent.DATA_NAME_SUPERIOR) is parent.agent0
381 finally:
382 AgentContext.remove(context_id)
383 AgentContext.remove(parent_id)
384
385
386 @pytest.mark.parametrize("project_name", [None, "demo"], ids=["global", "project"])
387 @pytest.mark.asyncio
388 async def test_agent_profile_set_uses_scope_and_preserves_subagent_profile(
389 monkeypatch, project_name
390 ) -> None:
391 import api.agent_profile_set as agent_profile_set
392
393 requested_scopes = []
394 monkeypatch.setattr(
395 agent_profile_set.subagents,
396 "get_agents_dict",
397 lambda scope: requested_scopes.append(scope)
398 or {"researcher": SimpleNamespace(title="Researcher", enabled=True)},
399 )
400 monkeypatch.setattr(
401 agent_profile_set,
402 "initialize_agent",
403 lambda override_settings=None: AgentConfig(
404 mcp_servers="",
405 profile=(override_settings or {}).get("agent_profile", "agent0"),
406 ),
407 )
408 monkeypatch.setattr(agent_profile_set, "save_tmp_chat", lambda _context: None)
409 monkeypatch.setattr(
410 agent_profile_set,
411 "mark_dirty_for_context",
412 lambda *_args, **_kwargs: None,
413 )
414
415 context_id = "ctx-profile-switch"
416 AgentContext.remove(context_id)
417 context = AgentContext(
418 config=AgentConfig(mcp_servers="", profile="agent0"),
419 id=context_id,
420 set_current=False,
421 )
422 child = Agent(1, AgentConfig(mcp_servers="", profile="developer"), context)
423 context.agent0.set_data(Agent.DATA_NAME_SUBORDINATE, child)
424 child.set_data(Agent.DATA_NAME_SUPERIOR, context.agent0)
425 if project_name:
426 context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, project_name)
427
428 try:
429 handler = agent_profile_set.SetAgentProfile.__new__(
430 agent_profile_set.SetAgentProfile
431 )
432 response = await handler.process(
433 {"context_id": context_id, "agent_profile": "researcher"},
434 request=None, # type: ignore[arg-type]
435 )
436
437 assert response["ok"] is True
438 assert context.config.profile == "researcher"
439 assert context.agent0.config.profile == "researcher"
440 assert child.config.profile == "developer"
441 assert requested_scopes == [project_name]
442 finally:
443 AgentContext.remove(context_id)