agent-dispatch 0.2.0__tar.gz → 0.2.1__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 (26) hide show
  1. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/PKG-INFO +1 -1
  2. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/pyproject.toml +1 -1
  3. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/__init__.py +1 -1
  4. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/cli.py +42 -15
  5. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/models.py +15 -5
  6. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/runner.py +51 -12
  7. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/server.py +27 -13
  8. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_cli.py +178 -0
  9. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_models.py +16 -0
  10. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_runner.py +93 -0
  11. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_server.py +28 -1
  12. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/.github/dependabot.yml +0 -0
  13. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/.github/workflows/ci.yml +0 -0
  14. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/.github/workflows/publish.yml +0 -0
  15. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/.gitignore +0 -0
  16. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/LICENSE +0 -0
  17. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/README.md +0 -0
  18. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/SECURITY.md +0 -0
  19. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/agents.example.yaml +0 -0
  20. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/assets/mascot.png +0 -0
  21. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/cache.py +0 -0
  22. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/config.py +0 -0
  23. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/__init__.py +0 -0
  24. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/conftest.py +0 -0
  25. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_cache.py +0 -0
  26. {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.2.0"
3
+ version = "0.2.1"
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.0"
3
+ __version__ = "0.2.1"
@@ -8,11 +8,31 @@ import subprocess
8
8
  from pathlib import Path
9
9
 
10
10
  import click
11
+ import yaml
12
+ from pydantic import ValidationError
11
13
 
12
14
  from .config import auto_describe, config_path, load_config, save_config
13
15
  from .models import AgentConfig, DispatchConfig, check_permission_mode, validate_agent_name
14
16
 
15
17
 
18
+ def _load_or_exit() -> DispatchConfig:
19
+ """Load config, exiting with a friendly error on malformed YAML or schema."""
20
+ try:
21
+ return load_config()
22
+ except ValidationError as e:
23
+ click.echo(click.style(
24
+ f"Error: config at {config_path()} has an invalid schema:", fg="red"
25
+ ))
26
+ click.echo(str(e))
27
+ raise SystemExit(1)
28
+ except yaml.YAMLError as e:
29
+ click.echo(click.style(
30
+ f"Error: config at {config_path()} is not valid YAML:", fg="red"
31
+ ))
32
+ click.echo(str(e))
33
+ raise SystemExit(1)
34
+
35
+
16
36
  @click.group()
17
37
  @click.version_option(package_name="agent-dispatch")
18
38
  def cli() -> None:
@@ -107,7 +127,7 @@ def add(
107
127
  click.echo(f"Error: {e}")
108
128
  raise SystemExit(1)
109
129
 
110
- config = load_config()
130
+ config = _load_or_exit()
111
131
  dir_path = Path(directory).resolve()
112
132
 
113
133
  if name in config.agents:
@@ -126,9 +146,9 @@ def add(
126
146
  max_budget_usd=max_budget,
127
147
  permission_mode=permission_mode,
128
148
  allowed_tools=[t.strip() for t in allowed_tools.split(",") if t.strip()]
129
- if allowed_tools else [],
149
+ if allowed_tools else None,
130
150
  disallowed_tools=[t.strip() for t in disallowed_tools.split(",") if t.strip()]
131
- if disallowed_tools else [],
151
+ if disallowed_tools else None,
132
152
  )
133
153
  if warning := check_permission_mode(permission_mode):
134
154
  click.echo(click.style(f"Warning: {warning}", fg="yellow"))
@@ -141,7 +161,7 @@ def add(
141
161
  @click.argument("name")
142
162
  def remove(name: str) -> None:
143
163
  """Remove an agent."""
144
- config = load_config()
164
+ config = _load_or_exit()
145
165
  if name not in config.agents:
146
166
  click.echo(f"Agent '{name}' not found.")
147
167
  raise SystemExit(1)
@@ -154,14 +174,20 @@ def remove(name: str) -> None:
154
174
  @cli.command("list")
155
175
  def list_agents() -> None:
156
176
  """List configured agents with health status."""
157
- config = load_config()
177
+ config = _load_or_exit()
158
178
  if not config.agents:
159
179
  click.echo("No agents configured. Run: agent-dispatch add <name> <directory>")
160
180
  return
161
181
 
162
182
  for name, agent in config.agents.items():
163
- healthy = agent.directory.is_dir()
164
- status = click.style("OK", fg="green") if healthy else click.style("NOT FOUND", fg="red")
183
+ try:
184
+ healthy = agent.directory.is_dir()
185
+ status_label = "OK" if healthy else "NOT FOUND"
186
+ status_color = "green" if healthy else "red"
187
+ except OSError:
188
+ status_label = "UNREADABLE"
189
+ status_color = "red"
190
+ status = click.style(status_label, fg=status_color)
165
191
  click.echo(f" {name} [{status}]")
166
192
  click.echo(f" dir: {agent.directory}")
167
193
  click.echo(f" desc: {agent.description}")
@@ -214,7 +240,7 @@ def update(
214
240
  disallowed_tools: str | None,
215
241
  ) -> None:
216
242
  """Update an existing agent's configuration."""
217
- config = load_config()
243
+ config = _load_or_exit()
218
244
  if name not in config.agents:
219
245
  click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
220
246
  raise SystemExit(1)
@@ -229,26 +255,27 @@ def update(
229
255
  agent.timeout = timeout
230
256
  updated.append("timeout")
231
257
  if model is not None:
232
- agent.model = None if model.lower() == "none" else model
258
+ agent.model = None if model.strip().lower() in ("none", "") else model
233
259
  updated.append("model")
234
260
  if max_budget is not None:
235
261
  agent.max_budget_usd = None if max_budget == 0 else max_budget
236
262
  updated.append("max_budget_usd")
237
263
  if permission_mode is not None:
238
- effective = None if permission_mode.lower() == "none" else permission_mode
264
+ stripped = permission_mode.strip()
265
+ effective = None if stripped.lower() in ("none", "") else stripped
239
266
  agent.permission_mode = effective
240
267
  if warning := check_permission_mode(effective):
241
268
  click.echo(click.style(f"Warning: {warning}", fg="yellow"))
242
269
  updated.append("permission_mode")
243
270
  if allowed_tools is not None:
244
- if allowed_tools.lower() == "none":
245
- agent.allowed_tools = []
271
+ if allowed_tools.strip().lower() in ("none", ""):
272
+ agent.allowed_tools = None
246
273
  else:
247
274
  agent.allowed_tools = [t.strip() for t in allowed_tools.split(",") if t.strip()]
248
275
  updated.append("allowed_tools")
249
276
  if disallowed_tools is not None:
250
- if disallowed_tools.lower() == "none":
251
- agent.disallowed_tools = []
277
+ if disallowed_tools.strip().lower() in ("none", ""):
278
+ agent.disallowed_tools = None
252
279
  else:
253
280
  agent.disallowed_tools = [
254
281
  t.strip() for t in disallowed_tools.split(",") if t.strip()
@@ -268,7 +295,7 @@ def update(
268
295
  @click.argument("task", default="What project is this? Describe in one sentence.")
269
296
  def test(name: str, task: str) -> None:
270
297
  """Test an agent by dispatching a task."""
271
- config = load_config()
298
+ config = _load_or_exit()
272
299
  if name not in config.agents:
273
300
  click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
274
301
  raise SystemExit(1)
@@ -15,14 +15,24 @@ KNOWN_PERMISSION_MODES = frozenset({
15
15
 
16
16
  def check_permission_mode(mode: str | None) -> str | None:
17
17
  """Return a warning message if mode is unknown, else None."""
18
- if mode and mode not in KNOWN_PERMISSION_MODES:
18
+ if not mode:
19
+ return None
20
+ trimmed = mode.strip()
21
+ if not trimmed:
22
+ return None
23
+ if trimmed not in KNOWN_PERMISSION_MODES:
19
24
  known = ", ".join(sorted(KNOWN_PERMISSION_MODES))
20
- return f"Unknown permission_mode: {mode!r}. Known values: {known}"
25
+ return f"Unknown permission_mode: {trimmed!r}. Known values: {known}"
21
26
  return None
22
27
 
23
28
 
24
29
  class AgentConfig(BaseModel):
25
- """Configuration for a single agent."""
30
+ """Configuration for a single agent.
31
+
32
+ `allowed_tools` / `disallowed_tools` use `None` to mean
33
+ "inherit from settings.default_*" and `[]` to mean "explicitly empty
34
+ (override defaults to no tools)".
35
+ """
26
36
 
27
37
  directory: Path
28
38
  description: str = ""
@@ -30,8 +40,8 @@ class AgentConfig(BaseModel):
30
40
  max_budget_usd: float | None = None
31
41
  model: str | None = None
32
42
  permission_mode: str | None = None
33
- allowed_tools: list[str] = Field(default_factory=list)
34
- disallowed_tools: list[str] = Field(default_factory=list)
43
+ allowed_tools: list[str] | None = None
44
+ disallowed_tools: list[str] | None = None
35
45
 
36
46
  @field_validator("directory", mode="before")
37
47
  @classmethod
@@ -29,9 +29,14 @@ _PERMISSION_PATTERNS = [
29
29
  ]
30
30
 
31
31
 
32
- def _classify_error(error_text: str) -> str:
33
- """Classify an error message into a category."""
34
- lower = error_text.lower()
32
+ def _classify_error(error_text: object) -> str:
33
+ """Classify an error message into a category.
34
+
35
+ Accepts any type and coerces to string — some claude CLI error paths
36
+ produce non-string values (None, dict) that would crash `.lower()`.
37
+ """
38
+ text = str(error_text) if error_text else ""
39
+ lower = text.lower()
35
40
  for pattern in _PERMISSION_PATTERNS:
36
41
  if pattern in lower:
37
42
  return "permission"
@@ -101,11 +106,18 @@ def _build_command(
101
106
  if permission_mode:
102
107
  cmd.extend(["--permission-mode", permission_mode])
103
108
 
104
- allowed = agent.allowed_tools or settings.default_allowed_tools
109
+ # None = inherit from settings; [] = explicitly empty (no inheritance)
110
+ allowed = (
111
+ agent.allowed_tools if agent.allowed_tools is not None
112
+ else settings.default_allowed_tools
113
+ )
105
114
  for tool in allowed:
106
115
  cmd.extend(["--allowedTools", tool])
107
116
 
108
- disallowed = agent.disallowed_tools or settings.default_disallowed_tools
117
+ disallowed = (
118
+ agent.disallowed_tools if agent.disallowed_tools is not None
119
+ else settings.default_disallowed_tools
120
+ )
109
121
  for tool in disallowed:
110
122
  cmd.extend(["--disallowedTools", tool])
111
123
 
@@ -227,22 +239,36 @@ def dispatch(
227
239
  data = json.loads(proc.stdout)
228
240
  except json.JSONDecodeError:
229
241
  # Fallback: treat stdout as plain text
242
+ success = proc.returncode == 0
243
+ text = proc.stdout.strip()
244
+ if success:
245
+ return DispatchResult(agent=agent_name, success=True, result=text)
246
+ error_text = text or f"claude exited with code {proc.returncode} (non-JSON output)"
247
+ error_type = _classify_error(error_text)
248
+ if error_type == "permission":
249
+ error_text += _permission_hint(agent_name)
230
250
  return DispatchResult(
231
251
  agent=agent_name,
232
- success=proc.returncode == 0,
233
- result=proc.stdout.strip(),
252
+ success=False,
253
+ result=text,
254
+ error=error_text,
255
+ error_type=error_type,
234
256
  )
235
257
 
236
258
  is_error = data.get("is_error", False)
237
259
  if is_error:
238
- error_text = data.get("result", "")
260
+ raw_result = data.get("result", "")
261
+ error_text = str(raw_result) if raw_result else (
262
+ f"Agent '{agent_name}' reported an error with no details "
263
+ f"(exit code {proc.returncode})"
264
+ )
239
265
  error_type = _classify_error(error_text)
240
266
  if error_type == "permission":
241
267
  error_text += _permission_hint(agent_name)
242
268
  return DispatchResult(
243
269
  agent=agent_name,
244
270
  success=False,
245
- result=data.get("result", ""),
271
+ result=str(raw_result) if raw_result else "",
246
272
  session_id=data.get("session_id"),
247
273
  cost_usd=data.get("total_cost_usd"),
248
274
  duration_ms=data.get("duration_ms"),
@@ -326,11 +352,21 @@ def dispatch_stream(
326
352
  text=True,
327
353
  env=env,
328
354
  )
329
- except OSError as e:
355
+ except FileNotFoundError as e:
330
356
  return DispatchResult(
331
357
  agent=agent_name, success=False, result="", error=str(e),
332
358
  error_type="not_found",
333
359
  )
360
+ except PermissionError as e:
361
+ return DispatchResult(
362
+ agent=agent_name, success=False, result="", error=str(e),
363
+ error_type="permission",
364
+ )
365
+ except OSError as e:
366
+ return DispatchResult(
367
+ agent=agent_name, success=False, result="", error=str(e),
368
+ error_type="cli_error",
369
+ )
334
370
 
335
371
  # Kill the process if it exceeds the timeout
336
372
  timed_out = threading.Event()
@@ -387,14 +423,17 @@ def dispatch_stream(
387
423
  if result_data:
388
424
  is_error = result_data.get("is_error", False)
389
425
  if is_error:
390
- error_text = result_data.get("result", "")
426
+ raw_result = result_data.get("result", "")
427
+ error_text = str(raw_result) if raw_result else (
428
+ f"Agent '{agent_name}' reported an error with no details"
429
+ )
391
430
  error_type = _classify_error(error_text)
392
431
  if error_type == "permission":
393
432
  error_text += _permission_hint(agent_name)
394
433
  return DispatchResult(
395
434
  agent=agent_name,
396
435
  success=False,
397
- result=result_data.get("result", ""),
436
+ result=str(raw_result) if raw_result else "",
398
437
  session_id=result_data.get("session_id"),
399
438
  cost_usd=result_data.get("total_cost_usd"),
400
439
  duration_ms=result_data.get("duration_ms"),
@@ -326,6 +326,7 @@ async def dispatch_parallel(
326
326
  "success": False,
327
327
  "result": "",
328
328
  "error": str(res),
329
+ "error_type": "cli_error",
329
330
  })
330
331
  else:
331
332
  output.append(res)
@@ -421,16 +422,22 @@ async def dispatch_stream(
421
422
  # Forward progress messages while the subprocess runs
422
423
  while not future.done():
423
424
  await asyncio.sleep(0.1)
424
- while not progress_queue.empty():
425
- msg = progress_queue.get_nowait()
425
+ while True:
426
+ try:
427
+ msg = progress_queue.get_nowait()
428
+ except queue.Empty:
429
+ break
426
430
  if ctx:
427
431
  await ctx.info(f"[{agent}] {msg[:300]}")
428
432
 
429
433
  result = await asyncio.wrap_future(future)
430
434
 
431
435
  # Drain any remaining messages
432
- while not progress_queue.empty():
433
- msg = progress_queue.get_nowait()
436
+ while True:
437
+ try:
438
+ msg = progress_queue.get_nowait()
439
+ except queue.Empty:
440
+ break
434
441
  if ctx:
435
442
  await ctx.info(f"[{agent}] {msg[:300]}")
436
443
 
@@ -642,7 +649,7 @@ async def add_agent(
642
649
  directory: Absolute path to the project directory.
643
650
  description: What this agent can do. Leave empty for auto-generation.
644
651
  timeout: Timeout in seconds (0 uses global default of 300).
645
- max_budget_usd: Max cost in USD per dispatch (0 = no limit).
652
+ max_budget_usd: Max cost in USD per dispatch (0 or omitted = no limit).
646
653
  permission_mode: Permission mode for the claude CLI
647
654
  (e.g. default, plan, bypassPermissions). Leave empty for default.
648
655
  allowed_tools: Comma-separated list of allowed tools
@@ -666,8 +673,17 @@ async def add_agent(
666
673
  return json.dumps({"error": f"Agent '{name}' already exists. Remove it first."})
667
674
 
668
675
  desc = description or auto_describe(dir_path)
669
- parsed_allowed = [t.strip() for t in allowed_tools.split(",") if t.strip()] if allowed_tools else []
670
- parsed_disallowed = [t.strip() for t in disallowed_tools.split(",") if t.strip()] if disallowed_tools else []
676
+ parsed_allowed = (
677
+ [t.strip() for t in allowed_tools.split(",") if t.strip()]
678
+ if allowed_tools else None
679
+ )
680
+ parsed_disallowed = (
681
+ [t.strip() for t in disallowed_tools.split(",") if t.strip()]
682
+ if disallowed_tools else None
683
+ )
684
+
685
+ if ctx and (warning := check_permission_mode(permission_mode or None)):
686
+ await ctx.info(f"Warning: {warning}")
671
687
 
672
688
  config.agents[name] = AgentConfig(
673
689
  directory=dir_path,
@@ -681,8 +697,6 @@ async def add_agent(
681
697
  save_config(config)
682
698
 
683
699
  if ctx:
684
- if warning := check_permission_mode(permission_mode or None):
685
- await ctx.info(f"Warning: {warning}")
686
700
  await ctx.info(f"Added agent '{name}' -> {dir_path}")
687
701
 
688
702
  result: dict = {
@@ -746,8 +760,8 @@ async def update_agent(
746
760
  name: Agent name to update.
747
761
  description: New description.
748
762
  timeout: New timeout in seconds (0 = don't change).
749
- max_budget_usd: Max cost in USD per dispatch (0 = don't change,
750
- negative = clear limit).
763
+ max_budget_usd: New max cost in USD per dispatch (0 = don't change;
764
+ pass a negative number to clear the limit).
751
765
  model: Model override. Pass "none" to clear.
752
766
  permission_mode: Permission mode. Pass "none" to clear.
753
767
  allowed_tools: Comma-separated allowed tools. Pass "none" to clear.
@@ -781,13 +795,13 @@ async def update_agent(
781
795
  updated.append("permission_mode")
782
796
  if allowed_tools:
783
797
  if allowed_tools.lower() == "none":
784
- agent.allowed_tools = []
798
+ agent.allowed_tools = None
785
799
  else:
786
800
  agent.allowed_tools = [t.strip() for t in allowed_tools.split(",") if t.strip()]
787
801
  updated.append("allowed_tools")
788
802
  if disallowed_tools:
789
803
  if disallowed_tools.lower() == "none":
790
- agent.disallowed_tools = []
804
+ agent.disallowed_tools = None
791
805
  else:
792
806
  agent.disallowed_tools = [
793
807
  t.strip() for t in disallowed_tools.split(",") if t.strip()
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import subprocess
6
7
  from pathlib import Path
7
8
  from unittest.mock import patch
8
9
 
@@ -11,6 +12,7 @@ from click.testing import CliRunner
11
12
 
12
13
  from agent_dispatch.cli import cli
13
14
  from agent_dispatch.config import load_config
15
+ from agent_dispatch.models import DispatchResult
14
16
 
15
17
 
16
18
  @pytest.fixture(autouse=True)
@@ -201,3 +203,179 @@ class TestUpdate:
201
203
  runner.invoke(cli, ["update", "proj", "--max-budget", "0"])
202
204
  config = load_config()
203
205
  assert config.agents["proj"].max_budget_usd is None
206
+
207
+ def test_update_empty_string_clears_model(self, tmp_path: Path):
208
+ """B2: --model "" should clear to None, not store empty string."""
209
+ agent_dir = tmp_path / "proj"
210
+ agent_dir.mkdir()
211
+ runner.invoke(cli, [
212
+ "add", "proj", str(agent_dir), "-d", "Test", "--model", "sonnet",
213
+ ])
214
+ result = runner.invoke(cli, ["update", "proj", "--model", ""])
215
+ assert result.exit_code == 0
216
+ config = load_config()
217
+ assert config.agents["proj"].model is None
218
+
219
+ def test_update_empty_string_clears_permission_mode(self, tmp_path: Path):
220
+ """B2: --permission-mode "" should clear to None."""
221
+ agent_dir = tmp_path / "proj"
222
+ agent_dir.mkdir()
223
+ runner.invoke(cli, [
224
+ "add", "proj", str(agent_dir), "-d", "Test",
225
+ "--permission-mode", "bypassPermissions",
226
+ ])
227
+ result = runner.invoke(cli, ["update", "proj", "--permission-mode", ""])
228
+ assert result.exit_code == 0
229
+ config = load_config()
230
+ assert config.agents["proj"].permission_mode is None
231
+
232
+ def test_update_allowed_tools_empty_clears_to_none(self, tmp_path: Path):
233
+ """B1+B2: --allowed-tools "" clears to None (inherit defaults)."""
234
+ agent_dir = tmp_path / "proj"
235
+ agent_dir.mkdir()
236
+ runner.invoke(cli, [
237
+ "add", "proj", str(agent_dir), "-d", "Test",
238
+ "--allowed-tools", "Bash,Read",
239
+ ])
240
+ runner.invoke(cli, ["update", "proj", "--allowed-tools", ""])
241
+ config = load_config()
242
+ assert config.agents["proj"].allowed_tools is None
243
+
244
+
245
+ class TestListUnreadable:
246
+ def test_list_unreadable_directory(self, tmp_path: Path):
247
+ """A4: is_dir() OSError should show UNREADABLE, not crash."""
248
+ agent_dir = tmp_path / "proj"
249
+ agent_dir.mkdir()
250
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
251
+
252
+ with patch(
253
+ "agent_dispatch.cli.Path.is_dir",
254
+ side_effect=PermissionError("access denied"),
255
+ ):
256
+ result = runner.invoke(cli, ["list"])
257
+ assert result.exit_code == 0
258
+ assert "UNREADABLE" in result.output
259
+
260
+
261
+ class TestListMalformedConfig:
262
+ def test_list_handles_bad_yaml(self, _isolated_config: Path):
263
+ """A5: malformed YAML shows friendly error, not traceback."""
264
+ _isolated_config.parent.mkdir(parents=True, exist_ok=True)
265
+ _isolated_config.write_text("agents: [not a dict\n") # invalid YAML
266
+ result = runner.invoke(cli, ["list"])
267
+ assert result.exit_code != 0
268
+ assert "not valid YAML" in result.output
269
+
270
+ def test_list_handles_bad_schema(self, _isolated_config: Path):
271
+ """A5: YAML that doesn't match schema shows friendly error."""
272
+ _isolated_config.parent.mkdir(parents=True, exist_ok=True)
273
+ _isolated_config.write_text("agents: 42\nsettings: {}\n")
274
+ result = runner.invoke(cli, ["list"])
275
+ assert result.exit_code != 0
276
+ assert "invalid schema" in result.output
277
+
278
+
279
+ class TestInit:
280
+ def test_init_creates_config(self, _isolated_config: Path):
281
+ config_file = _isolated_config
282
+ assert not config_file.exists()
283
+ with (
284
+ patch("agent_dispatch.cli.shutil.which", return_value=None),
285
+ ):
286
+ result = runner.invoke(cli, ["init"])
287
+ assert result.exit_code == 0
288
+ assert "Created config" in result.output
289
+ assert config_file.exists()
290
+
291
+ def test_init_existing_config(self, _isolated_config: Path):
292
+ config_file = _isolated_config
293
+ config_file.parent.mkdir(parents=True, exist_ok=True)
294
+ config_file.write_text("agents: {}\n")
295
+ with patch("agent_dispatch.cli.shutil.which", return_value=None):
296
+ result = runner.invoke(cli, ["init"])
297
+ assert "already exists" in result.output
298
+
299
+ def test_init_claude_not_found(self, _isolated_config: Path):
300
+ with patch("agent_dispatch.cli.shutil.which", return_value=None):
301
+ result = runner.invoke(cli, ["init"])
302
+ assert result.exit_code == 0
303
+ assert "claude CLI not found" in result.output
304
+
305
+ def test_init_registers_mcp_server(self, _isolated_config: Path):
306
+ with (
307
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
308
+ patch("agent_dispatch.cli.subprocess.run") as mock_run,
309
+ ):
310
+ mock_run.return_value = subprocess.CompletedProcess(
311
+ args=[], returncode=0, stdout="", stderr=""
312
+ )
313
+ result = runner.invoke(cli, ["init"])
314
+ assert "Registered MCP server" in result.output
315
+ mock_run.assert_called_once()
316
+ cmd = mock_run.call_args[0][0]
317
+ assert "claude" in cmd[0]
318
+ assert "mcp" in cmd
319
+ assert "add-json" in cmd
320
+
321
+ def test_init_mcp_registration_fails(self, _isolated_config: Path):
322
+ with (
323
+ patch("agent_dispatch.cli.shutil.which", side_effect=lambda x: f"/usr/bin/{x}"),
324
+ patch("agent_dispatch.cli.subprocess.run") as mock_run,
325
+ ):
326
+ mock_run.return_value = subprocess.CompletedProcess(
327
+ args=[], returncode=1, stdout="", stderr="registration failed"
328
+ )
329
+ result = runner.invoke(cli, ["init"])
330
+ assert "Failed to register" in result.output
331
+
332
+
333
+ class TestTestCommand:
334
+ def test_success(self, tmp_path: Path):
335
+ agent_dir = tmp_path / "proj"
336
+ agent_dir.mkdir()
337
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test agent"])
338
+ with patch("agent_dispatch.runner.dispatch") as mock_dispatch:
339
+ mock_dispatch.return_value = DispatchResult(
340
+ agent="proj", success=True, result="This is a test project.",
341
+ cost_usd=0.01, num_turns=1,
342
+ )
343
+ result = runner.invoke(cli, ["test", "proj"])
344
+ assert result.exit_code == 0
345
+ assert "This is a test project" in result.output
346
+ assert "$0.0100" in result.output
347
+
348
+ def test_agent_not_found(self):
349
+ result = runner.invoke(cli, ["test", "nonexistent"])
350
+ assert result.exit_code != 0
351
+ assert "not found" in result.output
352
+
353
+ def test_permission_error_shows_diagnosis(self, tmp_path: Path):
354
+ agent_dir = tmp_path / "proj"
355
+ agent_dir.mkdir()
356
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
357
+ with patch("agent_dispatch.runner.dispatch") as mock_dispatch:
358
+ mock_dispatch.return_value = DispatchResult(
359
+ agent="proj", success=False, result="",
360
+ error="permission denied for tool Bash",
361
+ error_type="permission",
362
+ )
363
+ result = runner.invoke(cli, ["test", "proj"])
364
+ assert result.exit_code != 0
365
+ assert "Diagnosis: permission error" in result.output
366
+ assert "bypassPermissions" in result.output
367
+
368
+ def test_timeout_error_shows_diagnosis(self, tmp_path: Path):
369
+ agent_dir = tmp_path / "proj"
370
+ agent_dir.mkdir()
371
+ runner.invoke(cli, ["add", "proj", str(agent_dir), "-d", "Test"])
372
+ with patch("agent_dispatch.runner.dispatch") as mock_dispatch:
373
+ mock_dispatch.return_value = DispatchResult(
374
+ agent="proj", success=False, result="",
375
+ error="timed out after 300s",
376
+ error_type="timeout",
377
+ )
378
+ result = runner.invoke(cli, ["test", "proj"])
379
+ assert result.exit_code != 0
380
+ assert "Diagnosis: timeout" in result.output
381
+ assert "--timeout 600" in result.output
@@ -26,6 +26,14 @@ def test_agent_config_defaults():
26
26
  assert agent.description == ""
27
27
  assert agent.max_budget_usd is None
28
28
  assert agent.model is None
29
+ # None = inherit from settings (B1)
30
+ assert agent.allowed_tools is None
31
+ assert agent.disallowed_tools is None
32
+
33
+
34
+ def test_agent_config_explicit_empty_tools():
35
+ """Empty list means 'explicitly no tools', distinct from None (inherit)."""
36
+ agent = AgentConfig(directory="/tmp", allowed_tools=[], disallowed_tools=[])
29
37
  assert agent.allowed_tools == []
30
38
  assert agent.disallowed_tools == []
31
39
 
@@ -106,6 +114,14 @@ class TestPermissionModeValidation:
106
114
  def test_empty_string_no_warning(self):
107
115
  assert check_permission_mode("") is None
108
116
 
117
+ def test_trims_whitespace(self):
118
+ """C1: ' bypassPermissions ' (trailing space) should be recognized as valid."""
119
+ assert check_permission_mode(" bypassPermissions ") is None
120
+ assert check_permission_mode("\tplan\n") is None
121
+
122
+ def test_whitespace_only_no_warning(self):
123
+ assert check_permission_mode(" ") is None
124
+
109
125
 
110
126
  def test_settings_default_permissions():
111
127
  s = Settings(
@@ -63,6 +63,22 @@ class TestErrorClassification:
63
63
  assert "bypassPermissions" in hint
64
64
  assert "allowed-tools" in hint
65
65
 
66
+ def test_classify_error_handles_none(self):
67
+ """A1: don't crash on None input."""
68
+ assert _classify_error(None) == "cli_error"
69
+
70
+ def test_classify_error_handles_empty_string(self):
71
+ """A1: don't crash on empty string."""
72
+ assert _classify_error("") == "cli_error"
73
+
74
+ def test_classify_error_handles_dict(self):
75
+ """A1: don't crash on non-string (e.g. dict from malformed JSON)."""
76
+ assert _classify_error({"weird": "value"}) == "cli_error"
77
+
78
+ def test_classify_error_handles_integer(self):
79
+ """A1: don't crash on int."""
80
+ assert _classify_error(42) == "cli_error"
81
+
66
82
 
67
83
  class TestBuildPrompt:
68
84
  def test_task_only(self):
@@ -156,6 +172,15 @@ class TestBuildCommand:
156
172
  assert "Bash" in cmd
157
173
  assert "Read" in cmd
158
174
 
175
+ def test_explicit_empty_allowed_tools_overrides_default(self):
176
+ """B1: allowed_tools=[] means 'explicitly none', not 'inherit'."""
177
+ settings = Settings(default_allowed_tools=["Bash", "Read"])
178
+ agent = AgentConfig(directory="/tmp", allowed_tools=[])
179
+ cmd = _build_command("claude", "hello", agent, settings)
180
+ assert "--allowedTools" not in cmd
181
+ assert "Bash" not in cmd
182
+ assert "Read" not in cmd
183
+
159
184
  def test_agent_allowed_tools_overrides_default(self):
160
185
  settings = Settings(default_allowed_tools=["Bash", "Read"])
161
186
  agent = AgentConfig(directory="/tmp", allowed_tools=["Edit"])
@@ -242,6 +267,47 @@ class TestDispatch:
242
267
  assert result.success
243
268
  assert result.result == "Just plain text"
244
269
 
270
+ @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
271
+ @patch("agent_dispatch.runner.subprocess.run")
272
+ def test_plain_text_fallback_failure_sets_error_type(self, mock_run, _which):
273
+ """A2: non-JSON stdout with non-zero exit should set error_type."""
274
+ mock_run.return_value = subprocess.CompletedProcess(
275
+ args=[], returncode=1, stdout="something broke", stderr=""
276
+ )
277
+ result = dispatch("test", "x", self.agent, self.settings)
278
+ assert not result.success
279
+ assert result.error_type == "cli_error"
280
+ assert "something broke" in result.error
281
+
282
+ @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
283
+ @patch("agent_dispatch.runner.subprocess.run")
284
+ def test_is_error_true_with_empty_result(self, mock_run, _which):
285
+ """A3: is_error=true with empty/missing result should get a fallback error message."""
286
+ mock_run.return_value = subprocess.CompletedProcess(
287
+ args=[], returncode=0,
288
+ stdout=json.dumps({"is_error": True, "result": ""}),
289
+ stderr="",
290
+ )
291
+ result = dispatch("test", "x", self.agent, self.settings)
292
+ assert not result.success
293
+ assert result.error_type == "cli_error"
294
+ assert "no details" in result.error
295
+
296
+ @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
297
+ @patch("agent_dispatch.runner.subprocess.run")
298
+ def test_is_error_true_with_none_result(self, mock_run, _which):
299
+ """A1+A3: is_error=true with result=null should not crash."""
300
+ mock_run.return_value = subprocess.CompletedProcess(
301
+ args=[], returncode=0,
302
+ stdout=json.dumps({"is_error": True, "result": None}),
303
+ stderr="",
304
+ )
305
+ result = dispatch("test", "x", self.agent, self.settings)
306
+ assert not result.success
307
+ assert result.error_type == "cli_error"
308
+ assert result.error # non-empty fallback
309
+ assert result.result == "" # coerced safely
310
+
245
311
  @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
246
312
  @patch("agent_dispatch.runner.subprocess.run")
247
313
  def test_context_is_prepended(self, mock_run, _which):
@@ -469,3 +535,30 @@ class TestDispatchStream:
469
535
  result = dispatch_stream("test", "hello", self.agent, self.settings)
470
536
  assert not result.success
471
537
  assert "No result received" in result.error or result.error
538
+
539
+ @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
540
+ @patch("agent_dispatch.runner.subprocess.Popen")
541
+ def test_popen_file_not_found_error_type(self, mock_popen, _which):
542
+ """B5: Popen FileNotFoundError maps to not_found."""
543
+ mock_popen.side_effect = FileNotFoundError("claude not found")
544
+ result = dispatch_stream("test", "hello", self.agent, self.settings)
545
+ assert not result.success
546
+ assert result.error_type == "not_found"
547
+
548
+ @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
549
+ @patch("agent_dispatch.runner.subprocess.Popen")
550
+ def test_popen_permission_error_type(self, mock_popen, _which):
551
+ """B5: Popen PermissionError maps to permission."""
552
+ mock_popen.side_effect = PermissionError("not executable")
553
+ result = dispatch_stream("test", "hello", self.agent, self.settings)
554
+ assert not result.success
555
+ assert result.error_type == "permission"
556
+
557
+ @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
558
+ @patch("agent_dispatch.runner.subprocess.Popen")
559
+ def test_popen_generic_os_error_type(self, mock_popen, _which):
560
+ """B5: Popen generic OSError maps to cli_error."""
561
+ mock_popen.side_effect = OSError("disk full")
562
+ result = dispatch_stream("test", "hello", self.agent, self.settings)
563
+ assert not result.success
564
+ assert result.error_type == "cli_error"
@@ -147,6 +147,32 @@ class TestDispatchParallel:
147
147
  assert results[0]["success"]
148
148
  assert not results[1]["success"]
149
149
 
150
+ @pytest.mark.asyncio
151
+ async def test_parallel_exception_has_error_type(self, tmp_path: Path):
152
+ """B4: when a dispatch raises (not returns failure), exception-path preserves error_type."""
153
+ config = _make_config(tmp_path)
154
+
155
+ def fake_dispatch(name, task, agent_config, settings, context=None, **kw):
156
+ if name == "db":
157
+ raise RuntimeError("boom")
158
+ return _ok_dispatch_result(name)
159
+
160
+ with (
161
+ patch.object(server, "_get_config", return_value=config),
162
+ patch("agent_dispatch.server.runner.dispatch", side_effect=fake_dispatch),
163
+ ):
164
+ dispatches = json.dumps([
165
+ {"agent": "infra", "task": "check"},
166
+ {"agent": "db", "task": "check"},
167
+ ])
168
+ raw = await server.dispatch_parallel(dispatches)
169
+ results = json.loads(raw)
170
+
171
+ assert results[0]["success"]
172
+ assert not results[1]["success"]
173
+ assert results[1]["error_type"] == "cli_error"
174
+ assert "boom" in results[1]["error"]
175
+
150
176
 
151
177
  class TestDispatchCaching:
152
178
  @pytest.mark.asyncio
@@ -755,7 +781,8 @@ class TestUpdateAgent:
755
781
  loaded = load_config(config_file)
756
782
  agent = loaded.agents["proj"]
757
783
  assert agent.permission_mode is None
758
- assert agent.allowed_tools == []
784
+ # "none" sentinel clears to None (inherit defaults), not []
785
+ assert agent.allowed_tools is None
759
786
  finally:
760
787
  os.environ.pop("AGENT_DISPATCH_CONFIG", None)
761
788
 
File without changes
File without changes