| 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 | ) |