agent-dispatch 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. agent_dispatch-0.3.0/CHANGELOG.md +98 -0
  2. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/PKG-INFO +7 -2
  3. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/README.md +6 -1
  4. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/pyproject.toml +1 -1
  5. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/__init__.py +1 -1
  6. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/cache.py +31 -7
  7. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/cli.py +189 -4
  8. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/server.py +24 -9
  9. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_cache.py +42 -0
  10. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_cli.py +335 -0
  11. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_server.py +105 -0
  12. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/.github/dependabot.yml +0 -0
  13. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/.github/workflows/ci.yml +0 -0
  14. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/.github/workflows/publish.yml +0 -0
  15. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/.gitignore +0 -0
  16. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/LICENSE +0 -0
  17. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/SECURITY.md +0 -0
  18. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/agents.example.yaml +0 -0
  19. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/assets/mascot.png +0 -0
  20. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/config.py +0 -0
  21. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/models.py +0 -0
  22. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/runner.py +0 -0
  23. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/__init__.py +0 -0
  24. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/conftest.py +0 -0
  25. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_config.py +0 -0
  26. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_models.py +0 -0
  27. {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_runner.py +0 -0
@@ -0,0 +1,98 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-05-08
11
+
12
+ ### Added
13
+ - `agent-dispatch doctor` CLI command — diagnoses installation issues:
14
+ checks `claude` CLI on PATH, `agent-dispatch` on PATH, config validity,
15
+ MCP registration with Claude Code, and per-agent directory health.
16
+ Exits non-zero if any blocking issue is found.
17
+ - `agent-dispatch describe <name>` CLI command — show one agent's full
18
+ configuration: directory, description, timeout, model, budget, permission
19
+ mode, tri-state tool fields (`(inherit defaults)` vs `(none — explicit
20
+ override)` vs explicit list), and which project files would be inherited.
21
+ - `--stream` flag for `agent-dispatch test` — surfaces live progress
22
+ (assistant text + tool use) while the agent works, useful for long
23
+ tasks where you'd otherwise see nothing until completion.
24
+
25
+ ### Fixed
26
+ - `list_agents` MCP tool no longer crashes the entire response when one
27
+ agent's directory is unreadable (`PermissionError`, network FS hiccup,
28
+ etc.). The bad agent now reports `healthy: "UNREADABLE"` and the rest
29
+ of the listing succeeds — matching the documented response shape.
30
+ - Dispatch cache key now includes `caller` and `goal`. Previously two
31
+ requests with the same `(agent, task, context)` but different framing
32
+ (e.g. `caller="frontend"` vs `caller="backend"`) would collide and the
33
+ second request would receive the cached response from the first — even
34
+ though the structured prompt sent to Claude is materially different.
35
+
36
+ ## [0.2.2] - 2026-04-17
37
+
38
+ ### Fixed
39
+ - `agent-dispatch list` now distinguishes `allowed_tools: None` (inherit
40
+ from settings defaults) from `allowed_tools: []` (explicitly no tools).
41
+ Previously both were rendered identically.
42
+
43
+ ## [0.2.1] - 2026-04-17
44
+
45
+ ### Fixed
46
+ - 13 bugs across the runner, server, CLI, config, and models:
47
+ - Runner: defensive coercion in `_classify_error` for non-string inputs;
48
+ fallback messages when `is_error=True` produces empty `result`;
49
+ correct error_type classification on plain-text stdout fallbacks;
50
+ orphan subprocess cleanup on stream exit paths.
51
+ - Server: up-front validation in `dispatch_parallel` (rejects bad items
52
+ before any dispatch runs); `dispatch_dialogue` surfaces per-turn errors;
53
+ `cache_stats` evicts expired entries before reporting.
54
+ - CLI: friendly error messages on malformed YAML / invalid schema;
55
+ `list` handles `OSError` from unreachable directories;
56
+ sentinel patterns for `update` to clear fields (`"none"` / `""`).
57
+ - Config: deduplication when collecting MCP servers from multiple paths.
58
+ - Models: tighter validation bounds (`ge=0`, `ge=1`).
59
+
60
+ ## [0.2.0] - 2026-04-16
61
+
62
+ ### Added
63
+ - Error classification — `DispatchResult.error_type` now reports
64
+ `permission`, `timeout`, `recursion`, `not_found`, or `cli_error`.
65
+ Permission errors include an actionable hint with suggested fixes.
66
+ - Permission management — agents and global settings support
67
+ `permission_mode`, `allowed_tools`, and `disallowed_tools`. Tool lists
68
+ use tri-state semantics: `None` inherits from defaults, `[]` overrides
69
+ to "no tools", a list specifies the allowed/disallowed set.
70
+ - `update_agent` MCP tool — modify an existing agent's configuration
71
+ without remove + re-add. CLI parity via `agent-dispatch update`.
72
+ - CLI tests for `init` and `test` commands.
73
+
74
+ ## [0.1.0] - 2026-04-10
75
+
76
+ ### Added
77
+ - Initial release.
78
+ - 11 MCP tools: `list_agents`, `add_agent`, `remove_agent`, `dispatch`,
79
+ `dispatch_session`, `dispatch_parallel` (with optional aggregation),
80
+ `dispatch_stream`, `dispatch_dialogue`, `cache_stats`, `cache_clear`.
81
+ - CLI: `init`, `add`, `remove`, `list`, `test`, `serve`.
82
+ - Recursion protection via `AGENT_DISPATCH_DEPTH` env var.
83
+ - In-memory TTL cache (thread-safe).
84
+ - Concurrency control via `asyncio.Semaphore` (default: 5 parallel
85
+ `claude -p` processes).
86
+ - Auto-description from `CLAUDE.md`, `README.md`, `pyproject.toml`,
87
+ `package.json`, `.mcp.json`, and stack/DB indicators.
88
+ - PyPI publishing via Trusted Publisher (OIDC).
89
+ - CI matrix on Python 3.10, 3.11, 3.12, 3.13.
90
+ - Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
91
+ commit SHAs for supply-chain integrity.
92
+
93
+ [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.3.0...HEAD
94
+ [0.3.0]: https://github.com/ginkida/agent-dispatch/compare/v0.2.2...v0.3.0
95
+ [0.2.2]: https://github.com/ginkida/agent-dispatch/compare/v0.2.1...v0.2.2
96
+ [0.2.1]: https://github.com/ginkida/agent-dispatch/compare/v0.2.0...v0.2.1
97
+ [0.2.0]: https://github.com/ginkida/agent-dispatch/compare/v0.1.0...v0.2.0
98
+ [0.1.0]: https://github.com/ginkida/agent-dispatch/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: MCP server that lets Claude Code agents delegate tasks to agents in other project directories
5
5
  Project-URL: Homepage, https://github.com/ginkida/agent-dispatch
6
6
  Project-URL: Repository, https://github.com/ginkida/agent-dispatch
@@ -62,6 +62,9 @@ agent-dispatch test infra
62
62
 
63
63
  # If agents hit permission errors, grant tool access:
64
64
  agent-dispatch update infra --permission-mode bypassPermissions
65
+
66
+ # If something doesn't work, run the diagnostic:
67
+ agent-dispatch doctor
65
68
  ```
66
69
 
67
70
  Done. Every Claude Code session now has access to all dispatch tools.
@@ -389,7 +392,9 @@ agent-dispatch MCP server
389
392
  | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
390
393
  | `agent-dispatch remove <name>` | Remove an agent |
391
394
  | `agent-dispatch list` | List agents with health status and permissions |
392
- | `agent-dispatch test <name> [task]` | Test an agent with a dispatch |
395
+ | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
396
+ | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
397
+ | `agent-dispatch doctor` | Diagnose installation: claude CLI, MCP registration, agent health |
393
398
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
394
399
 
395
400
  ## Requirements
@@ -32,6 +32,9 @@ agent-dispatch test infra
32
32
 
33
33
  # If agents hit permission errors, grant tool access:
34
34
  agent-dispatch update infra --permission-mode bypassPermissions
35
+
36
+ # If something doesn't work, run the diagnostic:
37
+ agent-dispatch doctor
35
38
  ```
36
39
 
37
40
  Done. Every Claude Code session now has access to all dispatch tools.
@@ -359,7 +362,9 @@ agent-dispatch MCP server
359
362
  | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
360
363
  | `agent-dispatch remove <name>` | Remove an agent |
361
364
  | `agent-dispatch list` | List agents with health status and permissions |
362
- | `agent-dispatch test <name> [task]` | Test an agent with a dispatch |
365
+ | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
366
+ | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
367
+ | `agent-dispatch doctor` | Diagnose installation: claude CLI, MCP registration, agent health |
363
368
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
364
369
 
365
370
  ## Requirements
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "MCP server that lets Claude Code agents delegate tasks to agents in other project directories"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """agent-dispatch: Delegate tasks between Claude Code agents across projects."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.3.0"
@@ -16,8 +16,11 @@ logger = logging.getLogger(__name__)
16
16
  class DispatchCache:
17
17
  """Thread-safe TTL cache for dispatch results.
18
18
 
19
- Keyed on (agent, task, context) — identical requests within the TTL
20
- window return the cached result without spawning a new subprocess.
19
+ Keyed on (agent, task, context, caller, goal) — identical requests within
20
+ the TTL window return the cached result without spawning a new subprocess.
21
+ `caller` and `goal` affect the prompt sent to the agent, so they must be
22
+ part of the key: otherwise two requests with different framing would
23
+ collide and return the wrong response.
21
24
  """
22
25
 
23
26
  def __init__(self, ttl: int = 300) -> None:
@@ -28,15 +31,34 @@ class DispatchCache:
28
31
  self._misses = 0
29
32
 
30
33
  @staticmethod
31
- def _make_key(agent: str, task: str, context: str | None) -> str:
34
+ def _make_key(
35
+ agent: str,
36
+ task: str,
37
+ context: str | None,
38
+ caller: str | None = None,
39
+ goal: str | None = None,
40
+ ) -> str:
32
41
  canonical = json.dumps(
33
- {"agent": agent, "task": task, "context": context or ""},
42
+ {
43
+ "agent": agent,
44
+ "task": task,
45
+ "context": context or "",
46
+ "caller": caller or "",
47
+ "goal": goal or "",
48
+ },
34
49
  sort_keys=True,
35
50
  )
36
51
  return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
37
52
 
38
- def get(self, agent: str, task: str, context: str | None = None) -> DispatchResult | None:
39
- key = self._make_key(agent, task, context)
53
+ def get(
54
+ self,
55
+ agent: str,
56
+ task: str,
57
+ context: str | None = None,
58
+ caller: str | None = None,
59
+ goal: str | None = None,
60
+ ) -> DispatchResult | None:
61
+ key = self._make_key(agent, task, context, caller, goal)
40
62
  with self._lock:
41
63
  entry = self._store.get(key)
42
64
  if entry is None:
@@ -56,10 +78,12 @@ class DispatchCache:
56
78
  task: str,
57
79
  result: DispatchResult,
58
80
  context: str | None = None,
81
+ caller: str | None = None,
82
+ goal: str | None = None,
59
83
  ) -> None:
60
84
  if not result.success:
61
85
  return # don't cache failures
62
- key = self._make_key(agent, task, context)
86
+ key = self._make_key(agent, task, context, caller, goal)
63
87
  with self._lock:
64
88
  self._store[key] = (time.monotonic(), result)
65
89
 
@@ -1,8 +1,9 @@
1
- """CLI: init, add, remove, list, test, serve."""
1
+ """CLI: init, add, remove, list, update, test, describe, doctor, serve."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  import shutil
7
8
  import subprocess
8
9
  from pathlib import Path
@@ -295,7 +296,11 @@ def update(
295
296
  @cli.command()
296
297
  @click.argument("name")
297
298
  @click.argument("task", default="What project is this? Describe in one sentence.")
298
- def test(name: str, task: str) -> None:
299
+ @click.option(
300
+ "--stream", "stream", is_flag=True,
301
+ help="Show live progress (assistant text + tool use) while the agent works.",
302
+ )
303
+ def test(name: str, task: str, stream: bool) -> None:
299
304
  """Test an agent by dispatching a task."""
300
305
  config = _load_or_exit()
301
306
  if name not in config.agents:
@@ -307,9 +312,18 @@ def test(name: str, task: str) -> None:
307
312
  click.echo(f"Task: {task}")
308
313
  click.echo("---")
309
314
 
310
- from .runner import dispatch
315
+ if stream:
316
+ from .runner import dispatch_stream
317
+
318
+ def _on_progress(msg: str) -> None:
319
+ click.echo(click.style(f" -> {msg}", fg="cyan"), err=True)
311
320
 
312
- result = dispatch(name, task, agent, config.settings)
321
+ result = dispatch_stream(
322
+ name, task, agent, config.settings, on_progress=_on_progress,
323
+ )
324
+ else:
325
+ from .runner import dispatch
326
+ result = dispatch(name, task, agent, config.settings)
313
327
 
314
328
  if result.success:
315
329
  click.echo(result.result)
@@ -330,6 +344,177 @@ def test(name: str, task: str) -> None:
330
344
  raise SystemExit(1)
331
345
 
332
346
 
347
+ @cli.command()
348
+ @click.argument("name")
349
+ def describe(name: str) -> None:
350
+ """Show full configuration for a single agent."""
351
+ config = _load_or_exit()
352
+ if name not in config.agents:
353
+ click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
354
+ raise SystemExit(1)
355
+
356
+ agent = config.agents[name]
357
+ try:
358
+ if agent.directory.is_dir():
359
+ status_label, status_color = "OK", "green"
360
+ else:
361
+ status_label, status_color = "NOT FOUND", "red"
362
+ except OSError:
363
+ status_label, status_color = "UNREADABLE", "red"
364
+ status = click.style(status_label, fg=status_color)
365
+
366
+ def _render_tools(tools: list[str] | None) -> str:
367
+ if tools is None:
368
+ return click.style("(inherit defaults)", fg="cyan")
369
+ if not tools:
370
+ return click.style("(none — explicit override)", fg="yellow")
371
+ return ", ".join(tools)
372
+
373
+ click.echo(f"{click.style(name, bold=True)} [{status}]")
374
+ click.echo(f" directory: {agent.directory}")
375
+ click.echo(f" description: {agent.description}")
376
+ click.echo(f" timeout: {agent.timeout}s")
377
+ if agent.model:
378
+ click.echo(f" model: {agent.model}")
379
+ if agent.max_budget_usd is not None:
380
+ click.echo(f" max_budget_usd: ${agent.max_budget_usd}")
381
+ if agent.permission_mode:
382
+ click.echo(f" permission_mode: {agent.permission_mode}")
383
+ click.echo(f" allowed_tools: {_render_tools(agent.allowed_tools)}")
384
+ click.echo(f" disallowed_tools: {_render_tools(agent.disallowed_tools)}")
385
+
386
+ # Surface project files used by auto_describe so the user can verify
387
+ # what context the dispatched agent will actually inherit.
388
+ try:
389
+ files: list[str] = []
390
+ for fname in ("CLAUDE.md", ".mcp.json", "README.md", "pyproject.toml", "package.json"):
391
+ if (agent.directory / fname).exists():
392
+ files.append(fname)
393
+ if files:
394
+ click.echo(f" project files: {', '.join(files)}")
395
+ except OSError:
396
+ pass
397
+
398
+
399
+ @cli.command()
400
+ def doctor() -> None:
401
+ """Diagnose the agent-dispatch setup and surface common issues."""
402
+ counters = {"issues": 0, "warnings": 0}
403
+
404
+ def section(title: str) -> None:
405
+ click.echo(f"\n{click.style(title, bold=True)}")
406
+
407
+ def ok(msg: str) -> None:
408
+ click.echo(f" [{click.style('OK', fg='green')}] {msg}")
409
+
410
+ def warn(msg: str) -> None:
411
+ counters["warnings"] += 1
412
+ click.echo(f" [{click.style('WARN', fg='yellow')}] {msg}")
413
+
414
+ def fail(msg: str) -> None:
415
+ counters["issues"] += 1
416
+ click.echo(f" [{click.style('FAIL', fg='red')}] {msg}")
417
+
418
+ section("Environment")
419
+ claude_path = shutil.which("claude")
420
+ if claude_path:
421
+ ok(f"claude CLI: {claude_path}")
422
+ else:
423
+ fail("claude CLI not found on PATH")
424
+ click.echo(" Install: https://docs.anthropic.com/en/docs/claude-code")
425
+
426
+ ad_path = shutil.which("agent-dispatch")
427
+ if ad_path:
428
+ ok(f"agent-dispatch CLI: {ad_path}")
429
+ else:
430
+ warn(
431
+ "agent-dispatch not on PATH "
432
+ "(MCP server still works via absolute path)"
433
+ )
434
+
435
+ section("Config")
436
+ cp = config_path()
437
+ config: DispatchConfig | None = None
438
+ if not cp.exists():
439
+ warn(f"Config not found: {cp}")
440
+ click.echo(" Run: agent-dispatch init")
441
+ else:
442
+ try:
443
+ config = load_config()
444
+ n = len(config.agents)
445
+ suffix = "agent" if n == 1 else "agents"
446
+ ok(f"Config: {cp} ({n} {suffix})")
447
+ except ValidationError as e:
448
+ fail(f"Config schema invalid: {cp}")
449
+ click.echo(f" {e}")
450
+ except yaml.YAMLError as e:
451
+ fail(f"Config not valid YAML: {cp}")
452
+ click.echo(f" {e}")
453
+
454
+ section("MCP registration")
455
+ if claude_path is None:
456
+ warn("Skipped (claude CLI missing)")
457
+ else:
458
+ try:
459
+ result = subprocess.run(
460
+ [claude_path, "mcp", "list"],
461
+ capture_output=True, text=True, timeout=10,
462
+ )
463
+ # Match the server name at the start of any line — `claude mcp list`
464
+ # prints `<name>: <command> - <status>`, and we want to avoid false
465
+ # positives from "agent-dispatch" appearing in command paths.
466
+ entry_re = re.compile(r"^agent-dispatch[:\s]", re.MULTILINE)
467
+ if result.returncode == 0 and entry_re.search(result.stdout):
468
+ ok("agent-dispatch is registered with Claude Code")
469
+ else:
470
+ warn("agent-dispatch is not registered with Claude Code")
471
+ click.echo(" Run: agent-dispatch init")
472
+ except subprocess.TimeoutExpired:
473
+ warn("Could not check MCP registration: claude mcp list timed out")
474
+ except (FileNotFoundError, PermissionError, OSError) as e:
475
+ warn(f"Could not check MCP registration: {e}")
476
+
477
+ section("Agents")
478
+ if config is None:
479
+ warn("Skipped (config could not be loaded)")
480
+ elif not config.agents:
481
+ warn("No agents configured. Add one: agent-dispatch add <name> <directory>")
482
+ else:
483
+ for name, agent in config.agents.items():
484
+ try:
485
+ if agent.directory.is_dir():
486
+ extras: list[str] = []
487
+ if (agent.directory / "CLAUDE.md").exists():
488
+ extras.append("CLAUDE.md")
489
+ if (agent.directory / ".mcp.json").exists():
490
+ extras.append(".mcp.json")
491
+ suffix = f" [{', '.join(extras)}]" if extras else ""
492
+ ok(f"{name}: {agent.directory}{suffix}")
493
+ else:
494
+ fail(f"{name}: directory missing - {agent.directory}")
495
+ except OSError as e:
496
+ fail(f"{name}: directory unreadable - {e}")
497
+
498
+ section("Summary")
499
+ issues = counters["issues"]
500
+ warnings = counters["warnings"]
501
+ if issues == 0 and warnings == 0:
502
+ click.echo(click.style("All checks passed.", fg="green"))
503
+ else:
504
+ parts: list[str] = []
505
+ if issues:
506
+ parts.append(click.style(
507
+ f"{issues} issue{'s' if issues != 1 else ''}", fg="red",
508
+ ))
509
+ if warnings:
510
+ parts.append(click.style(
511
+ f"{warnings} warning{'s' if warnings != 1 else ''}", fg="yellow",
512
+ ))
513
+ click.echo(", ".join(parts))
514
+ if issues > 0:
515
+ raise SystemExit(1)
516
+
517
+
333
518
  @cli.command()
334
519
  def serve() -> None:
335
520
  """Start the MCP server (stdio transport)."""
@@ -102,14 +102,28 @@ async def list_agents(ctx: Context | None = None) -> str:
102
102
 
103
103
  agents = []
104
104
  for name, agent in config.agents.items():
105
- healthy = agent.directory.is_dir()
105
+ # is_dir() can raise OSError (PermissionError, network FS hiccup, etc.).
106
+ # Fall back to "UNREADABLE" so a single broken agent doesn't crash the
107
+ # whole listing — per CLAUDE.md's documented response shape.
108
+ try:
109
+ healthy: bool | str = agent.directory.is_dir()
110
+ has_claude_md = (
111
+ (agent.directory / "CLAUDE.md").exists() if healthy else False
112
+ )
113
+ has_mcp_config = (
114
+ (agent.directory / ".mcp.json").exists() if healthy else False
115
+ )
116
+ except OSError:
117
+ healthy = "UNREADABLE"
118
+ has_claude_md = False
119
+ has_mcp_config = False
106
120
  entry: dict = {
107
121
  "name": name,
108
122
  "directory": str(agent.directory),
109
123
  "description": agent.description,
110
124
  "healthy": healthy,
111
- "has_claude_md": (agent.directory / "CLAUDE.md").exists() if healthy else False,
112
- "has_mcp_config": (agent.directory / ".mcp.json").exists() if healthy else False,
125
+ "has_claude_md": has_claude_md,
126
+ "has_mcp_config": has_mcp_config,
113
127
  }
114
128
  if agent.permission_mode:
115
129
  entry["permission_mode"] = agent.permission_mode
@@ -151,10 +165,11 @@ async def dispatch(
151
165
  if err := _validate_agent(config, agent):
152
166
  return err
153
167
 
154
- # Check cache
168
+ # Check cache. caller/goal are part of the key because they change the
169
+ # prompt sent to Claude and therefore the response.
155
170
  cache = _get_cache(config)
156
171
  if cache:
157
- cached = cache.get(agent, task, context or None)
172
+ cached = cache.get(agent, task, context or None, caller or None, goal or None)
158
173
  if cached:
159
174
  if ctx:
160
175
  await ctx.info(f"Cache hit for {agent} — returning cached result")
@@ -180,7 +195,7 @@ async def dispatch(
180
195
 
181
196
  # Populate cache
182
197
  if cache:
183
- cache.put(agent, task, result, context or None)
198
+ cache.put(agent, task, result, context or None, caller or None, goal or None)
184
199
 
185
200
  return result.model_dump_json(indent=2, exclude_none=True)
186
201
 
@@ -291,9 +306,9 @@ async def dispatch_parallel(
291
306
  item_caller = item.get("caller") or None
292
307
  item_goal = item.get("goal") or None
293
308
 
294
- # Check cache
309
+ # Check cache (caller/goal are part of the key — see dispatch())
295
310
  if cache:
296
- cached = cache.get(name, task, item_context)
311
+ cached = cache.get(name, task, item_context, item_caller, item_goal)
297
312
  if cached:
298
313
  d = json.loads(cached.model_dump_json(exclude_none=True))
299
314
  d["cached"] = True
@@ -313,7 +328,7 @@ async def dispatch_parallel(
313
328
  )
314
329
 
315
330
  if cache:
316
- cache.put(name, task, result, item_context)
331
+ cache.put(name, task, result, item_context, item_caller, item_goal)
317
332
 
318
333
  return json.loads(result.model_dump_json(exclude_none=True))
319
334
 
@@ -53,6 +53,48 @@ class TestCacheBasics:
53
53
  cache.put("a", "task", _fail_result())
54
54
  assert cache.get("a", "task") is None
55
55
 
56
+ def test_caller_affects_key(self):
57
+ """Different caller → different prompt → must not collide in cache."""
58
+ cache = DispatchCache(ttl=60)
59
+ cache.put("a", "task", _ok_result(text="for-frontend"), caller="frontend")
60
+ cache.put("a", "task", _ok_result(text="for-backend"), caller="backend")
61
+ assert cache.get("a", "task", caller="frontend").result == "for-frontend"
62
+ assert cache.get("a", "task", caller="backend").result == "for-backend"
63
+ # Different caller from what was stored → miss
64
+ assert cache.get("a", "task", caller="other") is None
65
+
66
+ def test_goal_affects_key(self):
67
+ """Different goal → different prompt → must not collide in cache."""
68
+ cache = DispatchCache(ttl=60)
69
+ cache.put("a", "task", _ok_result(text="for-debug"), goal="debug crash")
70
+ cache.put("a", "task", _ok_result(text="for-perf"), goal="optimize perf")
71
+ assert cache.get("a", "task", goal="debug crash").result == "for-debug"
72
+ assert cache.get("a", "task", goal="optimize perf").result == "for-perf"
73
+
74
+ def test_caller_and_goal_combined(self):
75
+ cache = DispatchCache(ttl=60)
76
+ cache.put(
77
+ "a", "task", _ok_result(text="A"),
78
+ caller="frontend", goal="debug",
79
+ )
80
+ # Same caller, different goal → miss
81
+ assert cache.get("a", "task", caller="frontend", goal="optimize") is None
82
+ # Same goal, different caller → miss
83
+ assert cache.get("a", "task", caller="backend", goal="debug") is None
84
+ # Both match → hit
85
+ assert cache.get(
86
+ "a", "task", caller="frontend", goal="debug",
87
+ ).result == "A"
88
+
89
+ def test_caller_none_vs_empty_string_collide(self):
90
+ """caller=None and caller="" canonicalize the same — both mean
91
+ 'no caller specified'. Document the behavior so future changes
92
+ don't silently break the contract."""
93
+ cache = DispatchCache(ttl=60)
94
+ cache.put("a", "task", _ok_result(text="anon"), caller=None)
95
+ assert cache.get("a", "task", caller="") is not None
96
+ assert cache.get("a", "task", caller=None) is not None
97
+
56
98
 
57
99
  class TestCacheTTL:
58
100
  def test_expired_entry_returns_none(self):
@@ -364,6 +364,297 @@ class TestInit:
364
364
  assert "Failed to register" in result.output
365
365
 
366
366
 
367
+ class TestDescribe:
368
+ """Tests for `agent-dispatch describe <name>` command."""
369
+
370
+ def test_describe_basic(self, tmp_path: Path):
371
+ agent_dir = tmp_path / "proj"
372
+ agent_dir.mkdir()
373
+ runner.invoke(cli, [
374
+ "add", "proj", str(agent_dir),
375
+ "-d", "My agent",
376
+ "--timeout", "600",
377
+ "--model", "sonnet",
378
+ "--max-budget", "1.5",
379
+ "--permission-mode", "bypassPermissions",
380
+ "--allowed-tools", "Bash,Read",
381
+ ])
382
+ result = runner.invoke(cli, ["describe", "proj"])
383
+ assert result.exit_code == 0
384
+ assert "proj" in result.output
385
+ assert "OK" in result.output
386
+ assert "My agent" in result.output
387
+ assert "600s" in result.output
388
+ assert "sonnet" in result.output
389
+ assert "$1.5" in result.output
390
+ assert "bypassPermissions" in result.output
391
+ assert "Bash, Read" in result.output
392
+
393
+ def test_describe_nonexistent(self):
394
+ result = runner.invoke(cli, ["describe", "nonexistent"])
395
+ assert result.exit_code != 0
396
+ assert "not found" in result.output
397
+
398
+ def test_describe_inherit_vs_explicit_empty(self, _isolated_config: Path, tmp_path: Path):
399
+ """Tools field should distinguish None (inherit) from [] (override)."""
400
+ agent_dir = tmp_path / "proj"
401
+ agent_dir.mkdir()
402
+ # Explicit empty list (override defaults)
403
+ _isolated_config.parent.mkdir(parents=True, exist_ok=True)
404
+ import yaml as _yaml
405
+ _yaml.safe_dump({
406
+ "agents": {
407
+ "proj": {
408
+ "directory": str(agent_dir),
409
+ "description": "Test",
410
+ "allowed_tools": [], # explicit override
411
+ # disallowed_tools omitted → None → inherit
412
+ },
413
+ },
414
+ }, _isolated_config.open("w"))
415
+ result = runner.invoke(cli, ["describe", "proj"])
416
+ assert result.exit_code == 0
417
+ # allowed_tools=[] → "(none — explicit override)"
418
+ assert "explicit override" in result.output
419
+ # disallowed_tools=None → "(inherit defaults)"
420
+ assert "inherit defaults" in result.output
421
+
422
+ def test_describe_missing_directory_shows_not_found(self, _isolated_config: Path):
423
+ _isolated_config.parent.mkdir(parents=True, exist_ok=True)
424
+ _isolated_config.write_text(
425
+ "agents:\n"
426
+ " proj:\n"
427
+ " directory: /nonexistent/xyz\n"
428
+ " description: Test\n"
429
+ )
430
+ result = runner.invoke(cli, ["describe", "proj"])
431
+ assert result.exit_code == 0
432
+ assert "NOT FOUND" in result.output
433
+
434
+ def test_describe_lists_project_files(self, tmp_path: Path):
435
+ agent_dir = tmp_path / "proj"
436
+ agent_dir.mkdir()
437
+ (agent_dir / "CLAUDE.md").write_text("# Proj")
438
+ (agent_dir / "README.md").write_text("# Proj README")
439
+ (agent_dir / ".mcp.json").write_text("{}")
440
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
441
+ result = runner.invoke(cli, ["describe", "proj"])
442
+ assert "CLAUDE.md" in result.output
443
+ assert "README.md" in result.output
444
+ assert ".mcp.json" in result.output
445
+
446
+
447
+ class TestDoctor:
448
+ """Tests for `agent-dispatch doctor` diagnostic command."""
449
+
450
+ def _patch_claude_mcp_list(
451
+ self, *, registered: bool = True, fail: bool = False, stdout: str | None = None,
452
+ ):
453
+ """Helper: patch subprocess.run for `claude mcp list` calls.
454
+
455
+ Mirrors the real CLI output format:
456
+ agent-dispatch: /path/to/agent-dispatch serve - Connected
457
+ """
458
+ if fail:
459
+ return patch(
460
+ "agent_dispatch.cli.subprocess.run",
461
+ side_effect=FileNotFoundError("no claude"),
462
+ )
463
+ if stdout is None:
464
+ if registered:
465
+ stdout = (
466
+ "agent-dispatch: /opt/homebrew/bin/agent-dispatch serve - Connected\n"
467
+ "foo: /usr/bin/foo serve - Connected\n"
468
+ )
469
+ else:
470
+ stdout = "foo: /usr/bin/foo serve - Connected\n"
471
+ return patch(
472
+ "agent_dispatch.cli.subprocess.run",
473
+ return_value=subprocess.CompletedProcess(
474
+ args=[], returncode=0, stdout=stdout, stderr="",
475
+ ),
476
+ )
477
+
478
+ def test_all_ok(self, tmp_path: Path):
479
+ agent_dir = tmp_path / "proj"
480
+ agent_dir.mkdir()
481
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
482
+ with (
483
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
484
+ self._patch_claude_mcp_list(registered=True),
485
+ ):
486
+ result = runner.invoke(cli, ["doctor"])
487
+ assert result.exit_code == 0, result.output
488
+ assert "claude CLI" in result.output
489
+ assert "All checks passed" in result.output
490
+ assert "FAIL" not in result.output
491
+
492
+ def test_claude_cli_missing(self, tmp_path: Path):
493
+ with patch("agent_dispatch.cli.shutil.which", return_value=None):
494
+ result = runner.invoke(cli, ["doctor"])
495
+ assert result.exit_code != 0
496
+ assert "claude CLI not found" in result.output
497
+ assert "FAIL" in result.output
498
+
499
+ def test_agent_dispatch_not_on_path_warns(self, tmp_path: Path):
500
+ def which(name: str):
501
+ return None if name == "agent-dispatch" else f"/usr/bin/{name}"
502
+
503
+ with (
504
+ patch("agent_dispatch.cli.shutil.which", side_effect=which),
505
+ self._patch_claude_mcp_list(registered=True),
506
+ ):
507
+ result = runner.invoke(cli, ["doctor"])
508
+ assert "agent-dispatch not on PATH" in result.output
509
+ assert "WARN" in result.output
510
+
511
+ def test_config_missing_warns(self, _isolated_config: Path):
512
+ """No config file → WARN, exit 0."""
513
+ assert not _isolated_config.exists()
514
+ with (
515
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
516
+ self._patch_claude_mcp_list(registered=True),
517
+ ):
518
+ result = runner.invoke(cli, ["doctor"])
519
+ assert result.exit_code == 0 # warnings don't fail
520
+ assert "Config not found" in result.output
521
+ assert "agent-dispatch init" in result.output
522
+
523
+ def test_config_invalid_yaml_fails(self, _isolated_config: Path):
524
+ _isolated_config.parent.mkdir(parents=True, exist_ok=True)
525
+ _isolated_config.write_text("agents: [not a dict\n")
526
+ with (
527
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
528
+ self._patch_claude_mcp_list(registered=True),
529
+ ):
530
+ result = runner.invoke(cli, ["doctor"])
531
+ assert result.exit_code != 0
532
+ assert "not valid YAML" in result.output
533
+
534
+ def test_config_invalid_schema_fails(self, _isolated_config: Path):
535
+ _isolated_config.parent.mkdir(parents=True, exist_ok=True)
536
+ _isolated_config.write_text("agents: 42\nsettings: {}\n")
537
+ with (
538
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
539
+ self._patch_claude_mcp_list(registered=True),
540
+ ):
541
+ result = runner.invoke(cli, ["doctor"])
542
+ assert result.exit_code != 0
543
+ assert "schema invalid" in result.output
544
+
545
+ def test_mcp_not_registered_warns(self, tmp_path: Path):
546
+ agent_dir = tmp_path / "proj"
547
+ agent_dir.mkdir()
548
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
549
+ with (
550
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
551
+ self._patch_claude_mcp_list(registered=False),
552
+ ):
553
+ result = runner.invoke(cli, ["doctor"])
554
+ assert result.exit_code == 0
555
+ assert "not registered with Claude Code" in result.output
556
+ assert "WARN" in result.output
557
+
558
+ def test_mcp_check_avoids_false_positive_in_path(self, tmp_path: Path):
559
+ """A line mentioning 'agent-dispatch' only in a path/command — but no
560
+ MCP server entry by that name — should NOT be treated as registered."""
561
+ agent_dir = tmp_path / "proj"
562
+ agent_dir.mkdir()
563
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
564
+ # Notice: 'agent-dispatch' appears only as a path component of another server
565
+ misleading = "other-server: /opt/bin/agent-dispatch-helper - Connected\n"
566
+ with (
567
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
568
+ self._patch_claude_mcp_list(stdout=misleading),
569
+ ):
570
+ result = runner.invoke(cli, ["doctor"])
571
+ assert "not registered with Claude Code" in result.output
572
+
573
+ def test_mcp_check_handles_timeout(self, tmp_path: Path):
574
+ agent_dir = tmp_path / "proj"
575
+ agent_dir.mkdir()
576
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
577
+ with (
578
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
579
+ patch(
580
+ "agent_dispatch.cli.subprocess.run",
581
+ side_effect=subprocess.TimeoutExpired(cmd="claude", timeout=10),
582
+ ),
583
+ ):
584
+ result = runner.invoke(cli, ["doctor"])
585
+ assert result.exit_code == 0
586
+ assert "timed out" in result.output
587
+
588
+ def test_missing_agent_directory_fails(self, _isolated_config: Path):
589
+ """Agent's directory was deleted after `add` → FAIL exit."""
590
+ # Write config pointing at a directory that doesn't exist
591
+ _isolated_config.parent.mkdir(parents=True, exist_ok=True)
592
+ _isolated_config.write_text(
593
+ "agents:\n"
594
+ " proj:\n"
595
+ " directory: /nonexistent/path/xyz\n"
596
+ " description: Test\n"
597
+ )
598
+ with (
599
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
600
+ self._patch_claude_mcp_list(registered=True),
601
+ ):
602
+ result = runner.invoke(cli, ["doctor"])
603
+ assert result.exit_code != 0
604
+ assert "directory missing" in result.output
605
+
606
+ def test_unreadable_directory_fails(self, tmp_path: Path):
607
+ agent_dir = tmp_path / "proj"
608
+ agent_dir.mkdir()
609
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
610
+ with (
611
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
612
+ self._patch_claude_mcp_list(registered=True),
613
+ patch(
614
+ "agent_dispatch.cli.Path.is_dir",
615
+ side_effect=PermissionError("denied"),
616
+ ),
617
+ ):
618
+ result = runner.invoke(cli, ["doctor"])
619
+ assert result.exit_code != 0
620
+ assert "unreadable" in result.output
621
+
622
+ def test_no_agents_warns(self, _isolated_config: Path):
623
+ """Config exists but has no agents → WARN, exit 0."""
624
+ _isolated_config.parent.mkdir(parents=True, exist_ok=True)
625
+ _isolated_config.write_text("agents: {}\nsettings: {}\n")
626
+ with (
627
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
628
+ self._patch_claude_mcp_list(registered=True),
629
+ ):
630
+ result = runner.invoke(cli, ["doctor"])
631
+ assert result.exit_code == 0
632
+ assert "No agents configured" in result.output
633
+
634
+ def test_lists_claude_md_and_mcp_json(self, tmp_path: Path):
635
+ agent_dir = tmp_path / "proj"
636
+ agent_dir.mkdir()
637
+ (agent_dir / "CLAUDE.md").write_text("# Proj")
638
+ (agent_dir / ".mcp.json").write_text("{}")
639
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
640
+ with (
641
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
642
+ self._patch_claude_mcp_list(registered=True),
643
+ ):
644
+ result = runner.invoke(cli, ["doctor"])
645
+ assert result.exit_code == 0
646
+ assert "CLAUDE.md" in result.output
647
+ assert ".mcp.json" in result.output
648
+
649
+ def test_summary_singular_plural(self, tmp_path: Path):
650
+ """One issue should say 'issue' not 'issues'."""
651
+ with patch("agent_dispatch.cli.shutil.which", return_value=None):
652
+ result = runner.invoke(cli, ["doctor"])
653
+ # Exactly one FAIL (claude CLI missing)
654
+ assert "1 issue" in result.output
655
+ assert "1 issues" not in result.output
656
+
657
+
367
658
  class TestTestCommand:
368
659
  def test_success(self, tmp_path: Path):
369
660
  agent_dir = tmp_path / "proj"
@@ -413,3 +704,47 @@ class TestTestCommand:
413
704
  assert result.exit_code != 0
414
705
  assert "Diagnosis: timeout" in result.output
415
706
  assert "--timeout 600" in result.output
707
+
708
+ def test_stream_uses_dispatch_stream(self, tmp_path: Path):
709
+ """--stream should call dispatch_stream, not dispatch, and forward progress."""
710
+ agent_dir = tmp_path / "proj"
711
+ agent_dir.mkdir()
712
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
713
+
714
+ def _fake_stream(name, task, agent, settings, on_progress=None, **_kw):
715
+ assert on_progress is not None
716
+ on_progress("Reading file foo.py")
717
+ on_progress("Using tool: Edit")
718
+ return DispatchResult(
719
+ agent=name, success=True, result="done",
720
+ cost_usd=0.01, num_turns=1,
721
+ )
722
+
723
+ with (
724
+ patch("agent_dispatch.runner.dispatch_stream", side_effect=_fake_stream),
725
+ patch("agent_dispatch.runner.dispatch") as plain_dispatch,
726
+ ):
727
+ result = runner.invoke(cli, ["test", "proj", "--stream"])
728
+ assert result.exit_code == 0
729
+ assert "done" in result.output
730
+ # Progress goes to stderr, captured into result.output by CliRunner
731
+ assert "Reading file foo.py" in result.output
732
+ assert "Using tool: Edit" in result.output
733
+ # Non-stream path must NOT be invoked
734
+ plain_dispatch.assert_not_called()
735
+
736
+ def test_no_stream_uses_dispatch(self, tmp_path: Path):
737
+ """Default (no --stream) should call dispatch, not dispatch_stream."""
738
+ agent_dir = tmp_path / "proj"
739
+ agent_dir.mkdir()
740
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
741
+ with (
742
+ patch("agent_dispatch.runner.dispatch") as mock_dispatch,
743
+ patch("agent_dispatch.runner.dispatch_stream") as mock_stream,
744
+ ):
745
+ mock_dispatch.return_value = DispatchResult(
746
+ agent="proj", success=True, result="ok",
747
+ )
748
+ runner.invoke(cli, ["test", "proj"])
749
+ mock_dispatch.assert_called_once()
750
+ mock_stream.assert_not_called()
@@ -199,6 +199,51 @@ class TestDispatchCaching:
199
199
  result = json.loads(raw)
200
200
  assert result.get("cached") is True
201
201
 
202
+ @pytest.mark.asyncio
203
+ async def test_cache_differentiates_callers(self, tmp_path: Path):
204
+ """Same (agent, task) but different caller should dispatch fresh —
205
+ caller/goal change the prompt, so the cached result is not equivalent."""
206
+ config = _make_config(tmp_path)
207
+ call_count = 0
208
+
209
+ def fake_dispatch(name, task, agent_config, settings, context=None, **kw):
210
+ nonlocal call_count
211
+ call_count += 1
212
+ return _ok_dispatch_result(name)
213
+
214
+ with (
215
+ patch.object(server, "_get_config", return_value=config),
216
+ patch("agent_dispatch.server.runner.dispatch", side_effect=fake_dispatch),
217
+ ):
218
+ await server.dispatch("infra", "check pods", caller="frontend")
219
+ assert call_count == 1
220
+ # Same task, different caller → must miss the cache and dispatch again
221
+ await server.dispatch("infra", "check pods", caller="backend")
222
+ assert call_count == 2
223
+ # Same caller again → hit
224
+ raw = await server.dispatch("infra", "check pods", caller="frontend")
225
+ assert call_count == 2
226
+ assert json.loads(raw).get("cached") is True
227
+
228
+ @pytest.mark.asyncio
229
+ async def test_cache_differentiates_goals(self, tmp_path: Path):
230
+ """Different goal → different prompt → cache miss."""
231
+ config = _make_config(tmp_path)
232
+ call_count = 0
233
+
234
+ def fake_dispatch(name, task, agent_config, settings, context=None, **kw):
235
+ nonlocal call_count
236
+ call_count += 1
237
+ return _ok_dispatch_result(name)
238
+
239
+ with (
240
+ patch.object(server, "_get_config", return_value=config),
241
+ patch("agent_dispatch.server.runner.dispatch", side_effect=fake_dispatch),
242
+ ):
243
+ await server.dispatch("infra", "check pods", goal="debug crash")
244
+ await server.dispatch("infra", "check pods", goal="optimize perf")
245
+ assert call_count == 2
246
+
202
247
  @pytest.mark.asyncio
203
248
  async def test_cache_disabled(self, tmp_path: Path):
204
249
  config = _make_config(tmp_path, cache_enabled=False)
@@ -628,6 +673,66 @@ class TestListAgentsPermissions:
628
673
  assert agents[0]["disallowed_tools"] == []
629
674
 
630
675
 
676
+ class TestListAgentsHealth:
677
+ """Health-check edge cases — directory missing, unreadable, etc."""
678
+
679
+ @pytest.mark.asyncio
680
+ async def test_list_handles_unreadable_directory(self, tmp_path: Path):
681
+ """One agent with PermissionError on is_dir() should NOT crash the
682
+ whole listing — that agent gets healthy='UNREADABLE', others OK."""
683
+ good = tmp_path / "good"
684
+ good.mkdir()
685
+ bad = tmp_path / "bad"
686
+ bad.mkdir()
687
+ config = DispatchConfig(
688
+ agents={
689
+ "good": AgentConfig(directory=good, description="ok"),
690
+ "bad": AgentConfig(directory=bad, description="unreadable"),
691
+ }
692
+ )
693
+ mock_ctx = AsyncMock()
694
+
695
+ original_is_dir = Path.is_dir
696
+
697
+ def selective_is_dir(self):
698
+ if self == bad:
699
+ raise PermissionError("denied")
700
+ return original_is_dir(self)
701
+
702
+ with (
703
+ patch.object(server, "_get_config", return_value=config),
704
+ patch.object(Path, "is_dir", selective_is_dir),
705
+ ):
706
+ raw = await server.list_agents(ctx=mock_ctx)
707
+ agents = json.loads(raw)
708
+ # Both agents present — one bad agent does not poison the list
709
+ assert len(agents) == 2
710
+ by_name = {a["name"]: a for a in agents}
711
+ assert by_name["good"]["healthy"] is True
712
+ assert by_name["bad"]["healthy"] == "UNREADABLE"
713
+ assert by_name["bad"]["has_claude_md"] is False
714
+ assert by_name["bad"]["has_mcp_config"] is False
715
+
716
+ @pytest.mark.asyncio
717
+ async def test_list_handles_nonexistent_directory(self, tmp_path: Path):
718
+ """Directory that doesn't exist → healthy=False, child checks=False."""
719
+ # Note: AgentConfig auto-resolves but doesn't require existence
720
+ config = DispatchConfig(
721
+ agents={
722
+ "ghost": AgentConfig(
723
+ directory=tmp_path / "does-not-exist",
724
+ description="missing",
725
+ ),
726
+ }
727
+ )
728
+ mock_ctx = AsyncMock()
729
+ with patch.object(server, "_get_config", return_value=config):
730
+ raw = await server.list_agents(ctx=mock_ctx)
731
+ agents = json.loads(raw)
732
+ assert agents[0]["healthy"] is False
733
+ assert agents[0]["has_claude_md"] is False
734
+
735
+
631
736
  class TestAddRemoveAgent:
632
737
  @pytest.mark.asyncio
633
738
  async def test_add_agent(self, tmp_path: Path):
File without changes