main
py 689 lines 23.1 KB
Raw
1 from __future__ import annotations
2
3 import shutil
4 import subprocess
5 import threading
6 import uuid
7 from dataclasses import dataclass, field
8 from pathlib import Path
9 import sys
10 from types import SimpleNamespace
11
12 import pytest
13 from flask import Flask
14
15 PROJECT_ROOT = Path(__file__).resolve().parents[4]
16 if str(PROJECT_ROOT) not in sys.path:
17 sys.path.insert(0, str(PROJECT_ROOT))
18
19 from agent import AgentContext
20 from helpers import files, projects, skills as skills_helper
21 from initialize import initialize_agent
22 from plugins._commands.api.commands import Commands
23 from plugins._commands.commands import connector_commands
24 from plugins._commands.extensions.python._functions.agent.AgentContext._process_chain.start._10_resolve_slash_command import (
25 ResolveSlashCommand,
26 )
27 from plugins._commands.helpers import commands as commands_helper
28
29
30 @dataclass
31 class ScopeFixture:
32 prefix: str
33 project_name: str
34 created_paths: list[str] = field(default_factory=list)
35
36
37 def _track_paths(scope: ScopeFixture, command: dict) -> dict:
38 for key in ("path", "config_path", "content_path"):
39 command_path = files.fix_dev_path(command.get(key, ""))
40 if command_path and command_path not in scope.created_paths:
41 scope.created_paths.append(command_path)
42 return command
43
44
45 def _save_command(
46 scope: ScopeFixture,
47 *,
48 project_name: str = "",
49 name: str,
50 description: str,
51 body: str = "",
52 argument_hint: str = "",
53 command_type: str = "text",
54 include_history: bool = False,
55 extra_frontmatter: dict | None = None,
56 ) -> dict:
57 command = commands_helper.save_command(
58 project_name=project_name,
59 name=name,
60 description=description,
61 body=body,
62 argument_hint=argument_hint,
63 command_type=command_type,
64 include_history=include_history,
65 extra_frontmatter=extra_frontmatter or {},
66 )
67 return _track_paths(scope, command)
68
69
70 def test_composer_picker_ignores_postfix_slashes() -> None:
71 if not shutil.which("node"):
72 pytest.skip("Node.js is required to execute the slash-picker regression.")
73
74 source = (Path(__file__).resolve().parents[1] / "webui" / "commands-slash-store.js").read_text(
75 encoding="utf-8"
76 )
77 start = source.index("function parseSlashInput(")
78 function_source = source[start : source.index("\n\nfunction notifyError", start)]
79 script = f"""
80 {function_source}
81
82 const leading = parseSlashInput("/goal objective", false);
83 if (!leading.active || leading.query !== "goal") throw new Error("leading command hidden");
84
85 const trailing = parseSlashInput("objective /goal", false);
86 if (trailing.active) throw new Error("postfix command opened the picker");
87
88 const path = parseSlashInput("Review /a0/usr/projects/example", false);
89 if (path.active) throw new Error("path opened the picker");
90
91 const resolvable = parseSlashInput("objective /goal");
92 if (!resolvable.active || resolvable.query !== "goal") throw new Error("postfix resolution broke");
93
94 const reference = parseReferenceInput("Compare @src/app", 16);
95 if (!reference.active || reference.query !== "src/app" || reference.start !== 8 || reference.end !== 16) throw new Error("reference token not found");
96
97 const middle = parseReferenceInput("Use @src/app then", 12);
98 if (!middle.active || middle.query !== "src/app") throw new Error("caret-local reference not found");
99
100 if (parseReferenceInput("mail@example.test").active) throw new Error("email opened reference picker");
101 if (parseReferenceInput("Use @[./src/app.py]").active) throw new Error("completed reference reopened picker");
102 if (fileQueryDirectory("../secret") !== null) throw new Error("parent traversal accepted");
103 if (fileQueryDirectory("mcp/server") !== null) throw new Error("MCP reference opened file browser");
104
105 const mcp = getMcpReferences({{
106 tools: {{
107 effective_policy: {{ mode: "custom", mcp_default: "block", allowed: ["mcp:allowed:read"], blocked: ["mcp:blocked:read"] }},
108 catalog: [
109 {{ id: "mcp:allowed:read", available: true }},
110 {{ id: "mcp:blocked:read", available: true }},
111 {{ id: "mcp:default-blocked:read", available: true }},
112 {{ id: "mcp:missing:read", available: false }},
113 ],
114 }},
115 }});
116 if (JSON.stringify(mcp) !== JSON.stringify([{{ name: "allowed", toolCount: 1 }}])) throw new Error("MCP policy scope leaked");
117 """
118 subprocess.run(["node", "-e", script], check=True, text=True)
119
120
121 def test_composer_reference_picker_uses_plain_reference_tokens() -> None:
122 plugin_root = Path(__file__).resolve().parents[1]
123 store = (plugin_root / "webui" / "commands-slash-store.js").read_text(encoding="utf-8")
124 menu = (
125 plugin_root / "extensions" / "webui" / "chat-input-box-start" / "commands-menu.html"
126 ).read_text(encoding="utf-8")
127
128 assert "@[agent/${key}]" in store
129 assert "@[skill/${name}]" in store
130 assert "value: `@[${displayPath}]`" in store
131 assert 'icon: isDirectory ? "folder" : "draft"' in store
132 assert 'icon: "person"' in store
133 assert 'icon: "auto_awesome"' in store
134 assert "skills.filter((skill) => !skill?.hidden)" in store
135 assert 'icon: "hub"' in store
136 assert "@[mcp/${name}]" in store
137 assert 'const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor"' in store
138 assert 'action: "list", context_id: contextId' in store
139 assert 'action: "load",' in store
140 assert "getMcpReferences(mcpResult?.state)" in store
141 assert 'mcp_servers_status' not in store
142 assert "composer-reference" in store
143 assert "node.dataset.label = reference.label" in store
144 assert 'callJsonApi("/chat_files_path_get"' in store
145 assert 'callJsonApi("/agents"' not in store
146 assert "filteredItems" in menu
147 assert "#chat-input .composer-reference" in menu
148 assert "content: attr(data-label)" in menu
149 assert "color: var(--color-highlight)" in menu
150 assert "color: var(--color-text)" in menu
151 assert "composer-reference.is-mcp" in menu
152 assert "background: transparent" in menu
153
154
155 @pytest.fixture
156 def scope_fixture() -> ScopeFixture:
157 suffix = uuid.uuid4().hex[:8]
158 scope = ScopeFixture(
159 prefix=f"commands-test-{suffix}",
160 project_name=f"commands_project_{suffix}",
161 )
162
163 yield scope
164
165 for path in reversed(scope.created_paths):
166 files.delete_file(path)
167
168 files.delete_dir(files.get_abs_path("usr", "projects", scope.project_name))
169
170
171 def _new_handler() -> Commands:
172 app = Flask("commands_plugin_tests")
173 app.secret_key = "commands-plugin-tests"
174 return Commands(app, threading.RLock())
175
176
177 def test_command_config_and_template_files_round_trip(
178 scope_fixture: ScopeFixture,
179 ) -> None:
180 command = _save_command(
181 scope_fixture,
182 name=f"Explain {scope_fixture.prefix}",
183 description="Explain a code sample clearly.",
184 body="Explain the sample.\n\n{raw}",
185 argument_hint="Paste code or describe the module.",
186 command_type="text",
187 extra_frontmatter={"category": "analysis", "audience": "team"},
188 )
189
190 config_path = Path(files.fix_dev_path(command["path"]))
191 content_path = Path(files.fix_dev_path(command["content_path"]))
192 assert config_path.name == f"explain-{scope_fixture.prefix}.command.yaml"
193 assert content_path.name == f"explain-{scope_fixture.prefix}.txt"
194
195 loaded = commands_helper.get_command(command["path"])
196 assert loaded["frontmatter_extra"] == {
197 "category": "analysis",
198 "audience": "team",
199 }
200
201 config_yaml = files.read_file(str(config_path))
202 assert "category: analysis" in config_yaml
203 assert "audience: team" in config_yaml
204 assert f"name: explain-{scope_fixture.prefix}" in config_yaml
205 assert "type: text" in config_yaml
206
207 template_text = files.read_file(str(content_path))
208 assert "Explain the sample." in template_text
209
210
211 def test_parse_arguments_and_render_template_support_flags() -> None:
212 parsed = commands_helper.parse_arguments(
213 '--git-url=https://github.com/acme/repo "quoted phrase" -v 30%'
214 )
215 assert parsed["flags"]["git_url"] == "https://github.com/acme/repo"
216 assert parsed["flags"]["v"] is True
217 assert parsed["positional"] == ["quoted phrase", "30%"]
218
219 invocation = commands_helper.parse_slash_invocation(
220 '/optimize 30% --mode fast --git-url=https://github.com/acme/repo'
221 )
222 rendered = commands_helper.render_text_template(
223 "Pct: {args.positional.0}\nMode: {args.flags.mode}\nURL: {args.flags.git_url}\nRaw: {raw}",
224 invocation,
225 )
226 assert rendered == (
227 "Pct: 30%\n"
228 "Mode: fast\n"
229 "URL: https://github.com/acme/repo\n"
230 "Raw: 30% --mode fast --git-url=https://github.com/acme/repo"
231 )
232
233 appended = commands_helper.render_text_template(
234 "Summarize this request.",
235 commands_helper.parse_slash_invocation("/summarize alpha beta"),
236 )
237 assert appended == "Summarize this request.\n\nArguments:\nalpha beta"
238
239 invalid_invocation = commands_helper.parse_slash_invocation("/?")
240 assert invalid_invocation["command_name"] == ""
241
242 postfix_invocation = commands_helper.parse_slash_invocation(
243 "Make goal execution tenacious\n/goal"
244 )
245 assert postfix_invocation["command_name"] == "goal"
246 assert postfix_invocation["raw_arguments"] == "Make goal execution tenacious"
247
248
249 @pytest.mark.asyncio
250 async def test_incoming_postfix_command_is_resolved_before_the_agent(
251 scope_fixture: ScopeFixture,
252 ) -> None:
253 command = _save_command(
254 scope_fixture,
255 name=f"Resolve {scope_fixture.prefix}",
256 description="Resolve a postfix command.",
257 body="Resolved: {raw}",
258 )
259 message = SimpleNamespace(message=f"from an AI /{command['name']}")
260 data = {
261 "args": (
262 SimpleNamespace(id=f"missing-{uuid.uuid4().hex}"),
263 None,
264 message,
265 )
266 }
267
268 await ResolveSlashCommand(agent=None).execute(data=data)
269
270 assert message.message == "Resolved: from an AI"
271
272
273 def test_list_effective_commands_project_overrides_global(
274 scope_fixture: ScopeFixture,
275 ) -> None:
276 shared_name = f"{scope_fixture.prefix}-shared"
277
278 _save_command(
279 scope_fixture,
280 name=shared_name,
281 description="global description",
282 body="global body",
283 command_type="text",
284 )
285 _save_command(
286 scope_fixture,
287 project_name=scope_fixture.project_name,
288 name=shared_name,
289 description="project description",
290 body="project body",
291 command_type="text",
292 )
293
294 project_commands, _ = commands_helper.list_effective_commands(
295 scope_fixture.project_name
296 )
297 global_commands, _ = commands_helper.list_effective_commands("")
298
299 assert {command["name"]: command for command in project_commands}[shared_name][
300 "description"
301 ] == "project description"
302 assert {command["name"]: command for command in global_commands}[shared_name][
303 "description"
304 ] == "global description"
305
306 scoped_commands, _ = commands_helper.list_scope_commands(scope_fixture.project_name)
307 scoped_command = next(
308 command for command in scoped_commands if command["name"] == shared_name
309 )
310 assert scoped_command["override_count"] == 1
311 assert scoped_command["override_scopes"] == ["Global"]
312
313
314 def test_duplicate_builtin_creates_same_name_project_override(
315 scope_fixture: ScopeFixture,
316 ) -> None:
317 builtin = next(
318 command
319 for command in commands_helper.list_builtin_commands()
320 if command["name"] == "new"
321 )
322
323 override = _track_paths(
324 scope_fixture,
325 commands_helper.duplicate_command(
326 builtin["path"],
327 project_name=scope_fixture.project_name,
328 ),
329 )
330
331 assert override["name"] == builtin["name"]
332 assert override["scope_label"] == "Project"
333 assert override["body"] == builtin["body"]
334
335 effective, _ = commands_helper.list_effective_commands(scope_fixture.project_name)
336 resolved = next(command for command in effective if command["name"] == "new")
337 assert resolved["path"] == override["path"]
338
339
340 def test_models_command_always_opens_modal():
341 result = connector_commands.run(
342 {
343 "invocation": {
344 "command_name": "models",
345 "raw_arguments": "default",
346 },
347 "context": {"context_id": ""},
348 }
349 )
350
351 assert result == {
352 "text": "",
353 "effects": [{"type": "open_plugin_config", "plugin": "_model_config"}],
354 }
355
356
357 def test_profile_command_opens_agent_manager() -> None:
358 result = connector_commands.run(
359 {
360 "invocation": {"command_name": "profile", "raw_arguments": ""},
361 "context": {"context_id": ""},
362 }
363 )
364
365 assert result == {
366 "text": "",
367 "effects": [{"type": "open_agent_editor", "view": "manage"}],
368 }
369
370
371 def test_permissions_command_edits_the_current_agent(
372 monkeypatch: pytest.MonkeyPatch,
373 ) -> None:
374 context = SimpleNamespace(config=SimpleNamespace(profile="developer"))
375 monkeypatch.setattr(connector_commands, "_context", lambda _context_id: context)
376 result = connector_commands.run(
377 {
378 "invocation": {"command_name": "permissions", "raw_arguments": ""},
379 "context": {"context_id": "ctx-1"},
380 }
381 )
382
383 assert result == {
384 "text": "",
385 "effects": [
386 {
387 "type": "open_agent_editor",
388 "view": "edit",
389 "profile_id": "developer",
390 }
391 ],
392 }
393
394
395 def test_permissions_command_refuses_the_internal_default(
396 monkeypatch: pytest.MonkeyPatch,
397 ) -> None:
398 context = SimpleNamespace(config=SimpleNamespace(profile="default"))
399 monkeypatch.setattr(connector_commands, "_context", lambda _context_id: context)
400
401 result = connector_commands.run(
402 {
403 "invocation": {"command_name": "permissions", "raw_arguments": ""},
404 "context": {"context_id": "ctx-1"},
405 }
406 )
407
408 assert result["effects"] == [
409 {
410 "type": "toast",
411 "message": "The Default utility profile has no editable permissions.",
412 "level": "error",
413 }
414 ]
415
416
417 def test_profile_command_quick_creates_with_the_agent_editor(
418 monkeypatch: pytest.MonkeyPatch,
419 ) -> None:
420 from plugins._agent_editor.helpers import editor
421
422 context = SimpleNamespace()
423 saved: list[tuple[str, str, object]] = []
424 monkeypatch.setattr(connector_commands, "_context", lambda _context_id: context)
425 monkeypatch.setattr(connector_commands.projects, "get_context_project_name", lambda _context: "")
426 monkeypatch.setattr(connector_commands.subagents, "get_available_agents_dict", lambda _project: {})
427 monkeypatch.setattr(
428 editor,
429 "save_easy_profile",
430 lambda title, instructions, profile_context: (
431 saved.append((title, instructions, profile_context)) or "source-scout",
432 {},
433 ),
434 )
435
436 result = connector_commands.run(
437 {
438 "invocation": {
439 "command_name": "profile",
440 "raw_arguments": '"Source Scout" "Verify every claim"',
441 "arguments": {
442 "positional": ["Source Scout", "Verify every claim"],
443 },
444 },
445 "context": {"context_id": "ctx-1"},
446 }
447 )
448
449 assert saved == [("Source Scout", "Verify every claim", context)]
450 assert result["effects"] == [
451 {"type": "toast", "message": "Created agent Source Scout.", "level": "success"},
452 {
453 "type": "test_agent_profile",
454 "profile_id": "source-scout",
455 "project_name": "",
456 },
457 ]
458
459
460 def test_stop_command_uses_the_composer_stop_operation(monkeypatch):
461 class Log:
462 def __init__(self):
463 self.progress = []
464 self.entries = []
465
466 def set_progress(self, value, *, active):
467 self.progress.append((value, active))
468
469 def log(self, **kwargs):
470 self.entries.append(kwargs)
471
472 context = SimpleNamespace(
473 id="stop-command-context",
474 paused=True,
475 log=Log(),
476 is_running=lambda: True,
477 kill_process=lambda: setattr(context, "killed", True),
478 killed=False,
479 )
480 monkeypatch.setattr(connector_commands, "_context", lambda _context_id: context)
481
482 result = connector_commands.run(
483 {
484 "invocation": {"command_name": "stop", "raw_arguments": ""},
485 "context": {"context_id": context.id},
486 }
487 )
488
489 assert context.killed is True
490 assert context.paused is False
491 assert context.log.progress == [("", False)]
492 assert context.log.entries == [
493 {"type": "info", "content": "Agent process stopped.", "finished": True}
494 ]
495 assert result == {
496 "text": "",
497 "effects": [
498 {"type": "toast", "message": "Agent process stopped.", "level": "success"}
499 ],
500 }
501
502
503 @pytest.mark.parametrize(("argument", "enabled"), [("on", True), ("off", False)])
504 def test_computer_use_command_guides_launcher_or_cli(
505 argument: str,
506 enabled: bool,
507 ):
508 result = connector_commands.run(
509 {
510 "invocation": {
511 "command_name": "computer-use",
512 "raw_arguments": argument,
513 },
514 "context": {"context_id": ""},
515 }
516 )
517
518 assert result["text"] == ""
519 assert result["effects"] == [
520 {
521 "type": "computer_use",
522 "enabled": enabled,
523 "fallback": (
524 "Computer Use permissions are controlled on the connected host. "
525 "Use Host access in A0 Launcher, or run "
526 f"`/computer-use {argument}` in the A0 CLI terminal."
527 ),
528 }
529 ]
530
531
532 @pytest.mark.asyncio
533 async def test_commands_api_crud_and_resolve_text_and_script(
534 scope_fixture: ScopeFixture,
535 ) -> None:
536 handler = _new_handler()
537 command_name = f"{scope_fixture.prefix}-context"
538
539 context = AgentContext(
540 config=initialize_agent({}),
541 set_current=True,
542 )
543 context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, scope_fixture.project_name)
544
545 try:
546 saved = await handler.process(
547 {
548 "action": "save",
549 "project_name": scope_fixture.project_name,
550 "name": command_name,
551 "description": "context override",
552 "command_type": "text",
553 "body": (
554 "Repo: {args.flags.git_url}\n"
555 "Mode: {args.flags.mode}\n"
556 "Raw: {raw}"
557 ),
558 },
559 None,
560 )
561 assert isinstance(saved, dict)
562 assert saved["ok"] is True
563 saved_command = _track_paths(scope_fixture, saved["command"])
564
565 loaded = await handler.process(
566 {
567 "action": "get",
568 "project_name": scope_fixture.project_name,
569 "path": saved_command["path"],
570 },
571 None,
572 )
573 assert isinstance(loaded, dict)
574 assert loaded["command"]["description"] == "context override"
575
576 resolved_text = await handler.process(
577 {
578 "action": "resolve",
579 "project_name": scope_fixture.project_name,
580 "path": saved_command["path"],
581 "slash_text": f"/{command_name} --git-url=https://github.com/acme/repo --mode deep",
582 "context_id": context.id,
583 },
584 None,
585 )
586 assert isinstance(resolved_text, dict)
587 assert resolved_text["ok"] is True
588 rendered_text = resolved_text["resolution"]["result"]["text"]
589 assert "Repo: https://github.com/acme/repo" in rendered_text
590 assert "Mode: deep" in rendered_text
591
592 script_saved = await handler.process(
593 {
594 "action": "save",
595 "project_name": scope_fixture.project_name,
596 "name": f"{command_name}-script",
597 "description": "script command",
598 "command_type": "script",
599 "include_history": True,
600 "body": (
601 "def run(payload):\n"
602 " flags = payload['arguments'].get('flags', {})\n"
603 " return {\n"
604 " 'text': f\"Script mode: {flags.get('mode', 'none')}\",\n"
605 " 'effects': [\n"
606 " {'type': 'toast', 'level': 'success', 'message': 'Script executed'}\n"
607 " ],\n"
608 " }\n"
609 ),
610 },
611 None,
612 )
613 assert isinstance(script_saved, dict)
614 assert script_saved["ok"] is True
615 script_command = _track_paths(scope_fixture, script_saved["command"])
616
617 resolved_script = await handler.process(
618 {
619 "action": "resolve",
620 "project_name": scope_fixture.project_name,
621 "path": script_command["path"],
622 "slash_text": f"/{command_name}-script --mode turbo",
623 "context_id": context.id,
624 },
625 None,
626 )
627 assert isinstance(resolved_script, dict)
628 assert resolved_script["ok"] is True
629 assert resolved_script["resolution"]["result"]["text"] == "Script mode: turbo"
630 assert resolved_script["resolution"]["result"]["effects"] == [
631 {
632 "type": "toast",
633 "level": "success",
634 "message": "Script executed",
635 }
636 ]
637
638 duplicated = await handler.process(
639 {
640 "action": "duplicate",
641 "project_name": scope_fixture.project_name,
642 "path": saved_command["path"],
643 },
644 None,
645 )
646 assert isinstance(duplicated, dict)
647 assert duplicated["ok"] is True
648 assert duplicated["command"]["name"].startswith(f"{command_name}-copy")
649 duplicated_command = _track_paths(scope_fixture, duplicated["command"])
650
651 effective_list = await handler.process(
652 {"action": "list_effective", "context_id": context.id},
653 None,
654 )
655 assert isinstance(effective_list, dict)
656 effective_by_name = {
657 command["name"]: command for command in effective_list["commands"]
658 }
659 assert effective_by_name[command_name]["description"] == "context override"
660 assert effective_by_name[command_name]["source_scope_key"] == "project"
661
662 scope_info = await handler.process(
663 {"action": "scope_info", "context_id": context.id},
664 None,
665 )
666 assert isinstance(scope_info, dict)
667 assert scope_info["scope"]["project_name"] == scope_fixture.project_name
668
669 deleted = await handler.process(
670 {
671 "action": "delete",
672 "project_name": scope_fixture.project_name,
673 "path": duplicated_command["path"],
674 },
675 None,
676 )
677 assert isinstance(deleted, dict)
678 assert deleted["ok"] is True
679 finally:
680 AgentContext.remove(context.id)
681 AgentContext.set_current("")
682
683
684 def test_plugin_scoped_skill_is_discoverable() -> None:
685 skill = skills_helper.find_skill("commands-create-slash-command")
686 assert skill is not None
687 assert skill.skill_md_path.as_posix().endswith(
688 "plugins/_commands/skills/commands-create-slash-command/SKILL.md"
689 )