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.
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/PKG-INFO +1 -1
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/pyproject.toml +1 -1
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/cli.py +42 -15
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/models.py +15 -5
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/runner.py +51 -12
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/server.py +27 -13
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_cli.py +178 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_models.py +16 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_runner.py +93 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_server.py +28 -1
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/.github/workflows/ci.yml +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/.github/workflows/publish.yml +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/.gitignore +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/LICENSE +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/README.md +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/SECURITY.md +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/agents.example.yaml +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/assets/mascot.png +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/cache.py +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/src/agent_dispatch/config.py +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/__init__.py +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/conftest.py +0 -0
- {agent_dispatch-0.2.0 → agent_dispatch-0.2.1}/tests/test_cache.py +0 -0
- {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.
|
|
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
|
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
164
|
-
|
|
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 =
|
|
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()
|
|
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
|
-
|
|
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()
|
|
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()
|
|
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 =
|
|
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
|
|
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: {
|
|
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] =
|
|
34
|
-
disallowed_tools: list[str] =
|
|
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:
|
|
33
|
-
"""Classify an error message into a category.
|
|
34
|
-
|
|
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
|
-
|
|
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 =
|
|
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=
|
|
233
|
-
result=
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
|
425
|
-
|
|
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
|
|
433
|
-
|
|
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 =
|
|
670
|
-
|
|
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:
|
|
750
|
-
negative
|
|
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
|
-
|
|
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
|
|
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
|