1
+from __future__ import annotations
2
+
3
+import asyncio
4
+import importlib
5
+import sys
6
+import types
7
+from dataclasses import dataclass
8
+from pathlib import Path
9
+
10
+
11
+@dataclass
12
+class _FakeResponse:
13
+ message: str
14
+ break_loop: bool
15
+ additional: dict | None = None
16
+
17
+
18
+class _FakeTool:
19
+ def __init__(
20
+ self,
21
+ agent,
22
+ name: str,
23
+ method: str | None,
24
+ args: dict | None,
25
+ message: str,
26
+ loop_data=None,
27
+ **kwargs,
28
+ ) -> None:
29
+ self.agent = agent
30
+ self.name = name
31
+ self.method = method
32
+ self.args = args or {}
33
+ self.message = message
34
+ self.loop_data = loop_data
35
+
36
+
37
+class _FakeAgent:
38
+ def __init__(self) -> None:
39
+ self.data = {}
40
+ self.context = types.SimpleNamespace(id="ctx")
41
+
42
+ def read_prompt(self, _name: str, **kwargs) -> str:
43
+ return f"deleted {kwargs.get('memory_count', 0)}"
44
+
45
+
46
+@dataclass
47
+class _FakeSkill:
48
+ name: str
49
+ description: str
50
+ path: Path
51
+ version: str = ""
52
+ tags: list[str] | None = None
53
+
54
+
55
+def _install_tool_stub(monkeypatch) -> None:
56
+ tool_stub = types.ModuleType("helpers.tool")
57
+ tool_stub.Tool = _FakeTool
58
+ tool_stub.Response = _FakeResponse
59
+ monkeypatch.setitem(sys.modules, "helpers.tool", tool_stub)
60
+
61
+
62
+def _load_skills_tool(monkeypatch, skill_root: Path):
63
+ _install_tool_stub(monkeypatch)
64
+
65
+ skills_stub = types.ModuleType("helpers.skills")
66
+ skills_stub.AGENT_DATA_NAME_LOADED_SKILLS = "loaded_skills"
67
+ skills_stub.MAX_ACTIVE_SKILLS = 20
68
+ fake_skill = _FakeSkill(
69
+ name="browser-forms",
70
+ description="Use for complex browser forms.",
71
+ path=skill_root,
72
+ tags=[],
73
+ )
74
+ skills_stub.list_skills = lambda *args, **kwargs: [fake_skill]
75
+ skills_stub.search_skills = lambda *args, **kwargs: [fake_skill]
76
+ skills_stub.find_skill = lambda *args, **kwargs: fake_skill
77
+ monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
78
+
79
+ print_style_stub = types.ModuleType("helpers.print_style")
80
+ print_style_stub.PrintStyle = lambda *args, **kwargs: types.SimpleNamespace(
81
+ print=lambda *a, **k: None
82
+ )
83
+ monkeypatch.setitem(sys.modules, "helpers.print_style", print_style_stub)
84
+
85
+ sys.modules.pop("tools.skills_tool", None)
86
+ return importlib.import_module("tools.skills_tool")
87
+
88
+
89
+def test_skills_tool_accepts_action_alias_for_search(monkeypatch, tmp_path: Path):
90
+ module = _load_skills_tool(monkeypatch, tmp_path)
91
+ tool = module.SkillsTool(
92
+ _FakeAgent(),
93
+ "skills_tool",
94
+ None,
95
+ {"action": "search", "query": "browser forms"},
96
+ "",
97
+ None,
98
+ )
99
+
100
+ response = asyncio.run(tool.execute(**tool.args))
101
+
102
+ assert "browser-forms" in response.message
103
+
104
+
105
+def test_skills_tool_read_file_action_reads_inside_skill_dir(
106
+ monkeypatch, tmp_path: Path
107
+):
108
+ skill_root = tmp_path / "browser-forms"
109
+ skill_root.mkdir()
110
+ (skill_root / "notes.md").write_text("Use labels before typing.\n", encoding="utf-8")
111
+ module = _load_skills_tool(monkeypatch, skill_root)
112
+ tool = module.SkillsTool(
113
+ _FakeAgent(),
114
+ "skills_tool",
115
+ None,
116
+ {
117
+ "action": "read_file",
118
+ "skill_name": "browser-forms",
119
+ "file_path": "notes.md",
120
+ },
121
+ "",
122
+ None,
123
+ )
124
+
125
+ response = asyncio.run(tool.execute(**tool.args))
126
+
127
+ assert "Skill file: browser-forms/notes.md" in response.message
128
+ assert "Use labels before typing." in response.message
129
+
130
+
131
+def test_memory_forget_tool_imports_plugin_memory_load(monkeypatch):
132
+ _install_tool_stub(monkeypatch)
133
+ monkeypatch.syspath_prepend(str(Path.cwd()))
134
+
135
+ class FakeDb:
136
+ def __init__(self) -> None:
137
+ self.calls = []
138
+
139
+ async def delete_documents_by_query(self, **kwargs):
140
+ self.calls.append(kwargs)
141
+ return ["memory-1"]
142
+
143
+ fake_db = FakeDb()
144
+
145
+ async def get_memory(_agent):
146
+ return fake_db
147
+
148
+ memory_stub = types.ModuleType("plugins._memory.helpers.memory")
149
+ memory_stub.Memory = types.SimpleNamespace(get=get_memory)
150
+ monkeypatch.setitem(sys.modules, "plugins._memory.helpers.memory", memory_stub)
151
+
152
+ sys.modules.pop("plugins._memory.tools.memory_load", None)
153
+ sys.modules.pop("plugins._memory.tools.memory_forget", None)
154
+ module = importlib.import_module("plugins._memory.tools.memory_forget")
155
+ tool = module.MemoryForget(
156
+ _FakeAgent(),
157
+ "memory_forget",
158
+ None,
159
+ {
160
+ "query": "codex memory forget token",
161
+ "threshold": 0.99,
162
+ "filter": "area=='codex_sweep'",
163
+ },
164
+ "",
165
+ None,
166
+ )
167
+
168
+ response = asyncio.run(tool.execute(**tool.args))
169
+
170
+ assert response.message == "deleted 1"
171
+ assert fake_db.calls == [
172
+ {
173
+ "query": "codex memory forget token",
174
+ "threshold": 0.99,
175
+ "filter": "area=='codex_sweep'",
176
+ }
177
+ ]
178
+
179
+
180
+def test_behaviour_adjustment_normalizes_duplicate_rules(monkeypatch):
181
+ _install_tool_stub(monkeypatch)
182
+ monkeypatch.syspath_prepend(str(Path.cwd()))
183
+
184
+ agent_stub = types.ModuleType("agent")
185
+ agent_stub.Agent = object
186
+ monkeypatch.setitem(sys.modules, "agent", agent_stub)
187
+
188
+ log_stub = types.ModuleType("helpers.log")
189
+ log_stub.LogItem = object
190
+ monkeypatch.setitem(sys.modules, "helpers.log", log_stub)
191
+
192
+ memory_stub = types.ModuleType("plugins._memory.helpers.memory")
193
+ memory_stub.get_memory_subdir_abs = lambda agent: "/tmp"
194
+ monkeypatch.setitem(sys.modules, "plugins._memory.helpers.memory", memory_stub)
195
+
196
+ sys.modules.pop("plugins._memory.tools.behaviour_adjustment", None)
197
+ module = importlib.import_module("plugins._memory.tools.behaviour_adjustment")
198
+
199
+ rules = module.normalize_ruleset(
200
+ "## Behavioral rules\n"
201
+ "* Favor Linux commands.\n"
202
+ "* Token rule.## Behavioral rules\n"
203
+ "* Favor Linux commands.\n"
204
+ "* Token rule."
205
+ )
206
+
207
+ assert rules == "## Behavioral rules\n* Favor Linux commands.\n* Token rule.\n"
208
+
209
+
210
+def test_notify_user_prompt_documents_numeric_priority_values():
211
+ prompt = Path("prompts/agent.system.tool.notify_user.md").read_text(
212
+ encoding="utf-8"
213
+ )
214
+
215
+ assert "priority values: `20` high urgency, `10` normal urgency" in prompt
216
+
217
+
218
+def test_tool_prompts_prevent_top_level_multi_tool():
219
+ tools_prompt = Path("prompts/agent.system.tools.md").read_text(encoding="utf-8")
220
+ communication_prompt = Path("prompts/agent.system.main.communication.md").read_text(
221
+ encoding="utf-8"
222
+ )
223
+ browser_prompt = Path("plugins/_browser/prompts/agent.system.tool.browser.md").read_text(
224
+ encoding="utf-8"
225
+ )
226
+
227
+ assert "There is no top-level `multi` or batch tool" in tools_prompt
228
+ assert "never an action name such as `read`, `write`, `terminal`, or `multi`" in communication_prompt
229
+ assert 'Never use `tool_name: "multi"`' in browser_prompt
230
+
231
+
232
+def _load_scheduler_tool(monkeypatch):
233
+ _install_tool_stub(monkeypatch)
234
+
235
+ scheduler_stub = types.ModuleType("helpers.task_scheduler")
236
+ scheduler_stub.TaskScheduler = object
237
+ scheduler_stub.ScheduledTask = type("ScheduledTask", (), {})
238
+ scheduler_stub.AdHocTask = type("AdHocTask", (), {})
239
+ scheduler_stub.PlannedTask = type("PlannedTask", (), {})
240
+ scheduler_stub.serialize_task = lambda task: {}
241
+ scheduler_stub.parse_datetime = lambda value: None
242
+ scheduler_stub.parse_task_plan = lambda value: None
243
+ scheduler_stub.serialize_datetime = lambda value: value
244
+ scheduler_stub.TaskState = types.SimpleNamespace(
245
+ IDLE="idle",
246
+ RUNNING="running",
247
+ )
248
+ scheduler_stub.TaskSchedule = type("TaskSchedule", (), {})
249
+ scheduler_stub.TaskPlan = type("TaskPlan", (), {})
250
+ monkeypatch.setitem(sys.modules, "helpers.task_scheduler", scheduler_stub)
251
+
252
+ agent_stub = types.ModuleType("agent")
253
+ agent_stub.AgentContext = types.SimpleNamespace(
254
+ get=lambda *args, **kwargs: None,
255
+ remove=lambda *args, **kwargs: None,
256
+ )
257
+ monkeypatch.setitem(sys.modules, "agent", agent_stub)
258
+
259
+ persist_chat_stub = types.ModuleType("helpers.persist_chat")
260
+ persist_chat_stub.remove_chat = lambda *args, **kwargs: None
261
+ monkeypatch.setitem(sys.modules, "helpers.persist_chat", persist_chat_stub)
262
+
263
+ projects_stub = types.ModuleType("helpers.projects")
264
+ projects_stub.get_context_project_name = lambda context: ""
265
+ projects_stub.load_basic_project_data = lambda project: {}
266
+ monkeypatch.setitem(sys.modules, "helpers.projects", projects_stub)
267
+
268
+ sys.modules.pop("tools.scheduler", None)
269
+ return importlib.import_module("tools.scheduler")
270
+
271
+
272
+def test_scheduler_accepts_action_alias(monkeypatch):
273
+ module = _load_scheduler_tool(monkeypatch)
274
+ tool = module.SchedulerTool(
275
+ _FakeAgent(),
276
+ "scheduler",
277
+ None,
278
+ {"action": "list_tasks"},
279
+ "",
280
+ None,
281
+ )
282
+
283
+ async def list_tasks(**kwargs):
284
+ return module.Response("listed", False)
285
+
286
+ tool.list_tasks = list_tasks
287
+
288
+ response = asyncio.run(tool.execute(**tool.args))
289
+
290
+ assert response.message == "listed"
291
+
292
+
293
+def test_scheduler_requires_action_field(monkeypatch):
294
+ module = _load_scheduler_tool(monkeypatch)
295
+ tool = module.SchedulerTool(
296
+ _FakeAgent(),
297
+ "scheduler",
298
+ "list_tasks",
299
+ {},
300
+ "",
301
+ None,
302
+ )
303
+
304
+ response = asyncio.run(tool.execute(**tool.args))
305
+
306
+ assert "Unknown scheduler action" in response.message
307
+
308
+
309
+def test_scheduler_create_defaults_to_dedicated_context(monkeypatch):
310
+ module = _load_scheduler_tool(monkeypatch)
311
+
312
+ class FakeTaskSchedule:
313
+ def __init__(self, **kwargs):
314
+ self.__dict__.update(kwargs)
315
+
316
+ def to_crontab(self):
317
+ return f"{self.minute} {self.hour} {self.day} {self.month} {self.weekday}"
318
+
319
+ class FakeScheduledTask:
320
+ @classmethod
321
+ def create(cls, **kwargs):
322
+ task = cls()
323
+ task.uuid = "task-1"
324
+ task.context_id = kwargs.get("context_id")
325
+ task.schedule = kwargs.get("schedule")
326
+ return task
327
+
328
+ class FakeScheduler:
329
+ def __init__(self):
330
+ self.added = None
331
+
332
+ async def add_task(self, task):
333
+ self.added = task
334
+
335
+ fake_scheduler = FakeScheduler()
336
+ module.TaskSchedule = FakeTaskSchedule
337
+ module.ScheduledTask = FakeScheduledTask
338
+ module.TaskScheduler = types.SimpleNamespace(get=lambda: fake_scheduler)
339
+ tool = module.SchedulerTool(
340
+ _FakeAgent(),
341
+ "scheduler",
342
+ None,
343
+ {
344
+ "action": "create_scheduled_task",
345
+ "name": "check stuff",
346
+ "prompt": "tell me if anything changed",
347
+ "schedule": {"minute": "0", "hour": "9", "day": "*", "month": "*", "weekday": "*"},
348
+ },
349
+ "",
350
+ None,
351
+ )
352
+
353
+ response = asyncio.run(tool.execute(**tool.args))
354
+
355
+ assert "created" in response.message
356
+ assert fake_scheduler.added.context_id is None
357
+
358
+
359
+def test_scheduler_local_timezone_alias_uses_current_user_timezone(monkeypatch):
360
+ module = _load_scheduler_tool(monkeypatch)
361
+
362
+ class FakeTaskSchedule:
363
+ def __init__(self, **kwargs):
364
+ self.__dict__.update(kwargs)
365
+
366
+ module.TaskSchedule = FakeTaskSchedule
367
+ module.Localization = types.SimpleNamespace(
368
+ get=lambda: types.SimpleNamespace(get_timezone=lambda: "Europe/Rome")
369
+ )
370
+
371
+ assert module._schedule_timezone({"schedule": {"timezone": "local"}}) == "Europe/Rome"
372
+ schedule = module._task_schedule_from_input(
373
+ {"minute": "30", "hour": "9", "day": "*", "month": "*", "weekday": "*", "timezone": "current"}
374
+ )
375
+
376
+ assert schedule.timezone == "Europe/Rome"
377
+
378
+
379
+def test_scheduler_invalid_timezone_returns_repairable_message(monkeypatch):
380
+ module = _load_scheduler_tool(monkeypatch)
381
+ tool = module.SchedulerTool(
382
+ _FakeAgent(),
383
+ "scheduler",
384
+ None,
385
+ {
386
+ "action": "create_scheduled_task",
387
+ "name": "bad timezone",
388
+ "prompt": "tell me something",
389
+ "schedule": {
390
+ "minute": "0",
391
+ "hour": "9",
392
+ "day": "*",
393
+ "month": "*",
394
+ "weekday": "*",
395
+ "timezone": "Mars/Base",
396
+ },
397
+ },
398
+ "",
399
+ None,
400
+ )
401
+
402
+ response = asyncio.run(tool.execute(**tool.args))
403
+
404
+ assert "Invalid timezone: Mars/Base" in response.message
405
+
406
+
407
+def test_scheduler_prompt_includes_update_timezone_and_dedicated_context():
408
+ project_root = Path(__file__).resolve().parents[1]
409
+ text = (
410
+ project_root / "prompts/agent.system.tool.scheduler.md"
411
+ ).read_text(encoding="utf-8")
412
+
413
+ assert "update_task" in text
414
+ assert "timezone" in text
415
+ assert "IANA" in text
416
+ assert "dedicated context" in text
417
+
418
+
419
+def test_skills_prompt_renders_catalog_placeholder():
420
+ project_root = Path(__file__).resolve().parents[1]
421
+ text = (project_root / "prompts/agent.system.skills.md").read_text(
422
+ encoding="utf-8"
423
+ )
424
+
425
+ assert "{{skills}}" in text
426
+
427
+
428
+def test_corrected_tool_prompts_only_teach_action_contract():
429
+ project_root = Path(__file__).resolve().parents[1]
430
+ prompt_paths = [
431
+ project_root / "plugins/_text_editor/prompts/agent.system.tool.text_editor.md",
432
+ project_root / "prompts/agent.system.tool.skills.md",
433
+ project_root / "prompts/agent.system.tool.scheduler.md",
434
+ project_root / "plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md",
435
+ project_root / "plugins/_office/prompts/agent.system.tool.document_artifact.md",
436
+ project_root / "plugins/_office/skills/document-artifacts/SKILL.md",
437
+ project_root / "plugins/_office/skills/markdown-documents/SKILL.md",
438
+ project_root / "plugins/_office/skills/writer-documents/SKILL.md",
439
+ project_root / "plugins/_office/skills/calc-spreadsheets/SKILL.md",
440
+ project_root / "plugins/_office/skills/impress-presentations/SKILL.md",
441
+ ]
442
+ forbidden = (
443
+ "text_editor:",
444
+ "skills_tool:",
445
+ "scheduler:",
446
+ "document_artifact:",
447
+ "`method`",
448
+ "`op`",
449
+ "`operation`",
450
+ "alias",
451
+ )
452
+
453
+ for path in prompt_paths:
454
+ text = path.read_text(encoding="utf-8")
455
+ assert "action" in text
456
+ for token in forbidden:
457
+ assert token not in text
458
+
459
+
460
+def test_computer_use_remote_is_skill_gated():
461
+ project_root = Path(__file__).resolve().parents[1]
462
+ prompt_path = (
463
+ project_root
464
+ / "plugins/_a0_connector/prompts/agent.system.tool.computer_use_remote.md"
465
+ )
466
+ skill_text = (
467
+ project_root / "skills/computer-use-remote/SKILL.md"
468
+ ).read_text(encoding="utf-8")
469
+
470
+ assert not prompt_path.exists()
471
+ assert '"tool_name": "computer_use_remote"' in skill_text
472
+ assert "Beta local desktop control" in skill_text