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.
- agent_dispatch-0.3.0/CHANGELOG.md +98 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/PKG-INFO +7 -2
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/README.md +6 -1
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/pyproject.toml +1 -1
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/cache.py +31 -7
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/cli.py +189 -4
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/server.py +24 -9
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_cache.py +42 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_cli.py +335 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_server.py +105 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/.github/workflows/ci.yml +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/.github/workflows/publish.yml +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/.gitignore +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/LICENSE +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/SECURITY.md +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/agents.example.yaml +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/assets/mascot.png +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/config.py +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/models.py +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/src/agent_dispatch/runner.py +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/__init__.py +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/conftest.py +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_config.py +0 -0
- {agent_dispatch-0.2.2 → agent_dispatch-0.3.0}/tests/test_models.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
112
|
-
"has_mcp_config":
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|