drydock-cli 3.0.52__tar.gz → 3.0.54__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 (72) hide show
  1. {drydock_cli-3.0.52/drydock_cli.egg-info → drydock_cli-3.0.54}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tools/__init__.py +88 -12
  3. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/app.py +39 -0
  4. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/widgets.py +1 -1
  5. {drydock_cli-3.0.52 → drydock_cli-3.0.54/drydock_cli.egg-info}/PKG-INFO +1 -1
  6. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/SOURCES.txt +1 -0
  7. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/pyproject.toml +1 -1
  8. drydock_cli-3.0.54/tests/test_dispatch.py +62 -0
  9. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_tui.py +37 -0
  10. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/LICENSE +0 -0
  11. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/NOTICE +0 -0
  12. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/README.md +0 -0
  13. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/__init__.py +0 -0
  14. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/__main__.py +0 -0
  15. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/agent.py +0 -0
  16. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/bash_safety.py +0 -0
  17. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/cli.py +0 -0
  18. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/compaction.py +0 -0
  19. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/config.py +0 -0
  20. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/detect.py +0 -0
  21. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/gittools.py +0 -0
  22. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/graphrag.py +0 -0
  23. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/guards.py +0 -0
  24. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/loop_detect.py +0 -0
  25. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/providers.py +0 -0
  26. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/skills.py +0 -0
  27. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tool_registry.py +0 -0
  28. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/__init__.py +0 -0
  29. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/approval.py +0 -0
  30. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/messages.py +0 -0
  31. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tuning.py +0 -0
  32. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/web.py +0 -0
  33. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/dependency_links.txt +0 -0
  34. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/entry_points.txt +0 -0
  35. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/requires.txt +0 -0
  36. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/top_level.txt +0 -0
  37. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/setup.cfg +0 -0
  38. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_approval.py +0 -0
  39. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_back_command.py +0 -0
  40. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_bash_process_group.py +0 -0
  41. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_bash_safety.py +0 -0
  42. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_bash_timeout_network.py +0 -0
  43. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_cli_agents.py +0 -0
  44. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_compact_command.py +0 -0
  45. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_compaction.py +0 -0
  46. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_config.py +0 -0
  47. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_config_migration.py +0 -0
  48. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_context_limit_config.py +0 -0
  49. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_detect.py +0 -0
  50. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_empty_response.py +0 -0
  51. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_failure_loop.py +0 -0
  52. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_first_run_setup.py +0 -0
  53. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_gittools.py +0 -0
  54. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_graphrag.py +0 -0
  55. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_guards_and_tools.py +0 -0
  56. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_hallucinated_tools.py +0 -0
  57. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_leaked_tool_call.py +0 -0
  58. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_loop_detect.py +0 -0
  59. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_oneshot_unreachable.py +0 -0
  60. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_plan_autocontinue.py +0 -0
  61. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_providers_unreachable.py +0 -0
  62. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_runaway_repetition.py +0 -0
  63. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_skills.py +0 -0
  64. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_stop.py +0 -0
  65. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_streaming_newlines.py +0 -0
  66. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_subagent.py +0 -0
  67. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_todo.py +0 -0
  68. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_tool_arg_parsing.py +0 -0
  69. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_tools_undo.py +0 -0
  70. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_tuning.py +0 -0
  71. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_vision_input.py +0 -0
  72. {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_web_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.52
3
+ Version: 3.0.54
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -161,6 +161,34 @@ SCHEMAS = [
161
161
  "required": ["prompt"],
162
162
  },
163
163
  },
164
+ {
165
+ "name": "Dispatch",
166
+ "description": (
167
+ "Run SEVERAL read-only investigation sub-agents at once (in "
168
+ "parallel), each in its own fresh context with Read/Glob/Grep/Bash. "
169
+ "Use it to answer multiple INDEPENDENT questions concurrently, e.g. "
170
+ "'where is auth handled?', 'how does the DB layer work?', 'what tests "
171
+ "exist?'. Returns each agent's summary. They cannot write or recurse."
172
+ ),
173
+ "input_schema": {
174
+ "type": "object",
175
+ "properties": {
176
+ "tasks": {
177
+ "type": "array",
178
+ "description": "Up to 6 independent investigation tasks.",
179
+ "items": {
180
+ "type": "object",
181
+ "properties": {
182
+ "prompt": {"type": "string", "description": "The self-contained task."},
183
+ "label": {"type": "string", "description": "Optional short label."},
184
+ },
185
+ "required": ["prompt"],
186
+ },
187
+ },
188
+ },
189
+ "required": ["tasks"],
190
+ },
191
+ },
164
192
  {
165
193
  "name": "GitStatus",
166
194
  "description": (
@@ -787,15 +815,10 @@ _SUBAGENT_SYSTEM = (
787
815
  )
788
816
 
789
817
 
790
- def tool_task(params: dict, config: dict) -> str:
791
- """Spawn a read-only sub-agent with a FRESH context for a focused
792
- exploration task, returning only its final summary. Keeps big searches out
793
- of the main agent's context. Cannot recurse (no `task` tool) and cannot
794
- write (no Write/Edit), so it can never corrupt the parent's work."""
795
- prompt = (params.get("prompt") or params.get("description") or "").strip()
796
- if not prompt:
797
- return "Error: `task` needs a `prompt` describing what to investigate."
798
- # Lazy import to avoid a tools <-> agent import cycle.
818
+ def _run_subagent(prompt: str, config: dict) -> str:
819
+ """Run one read-only sub-agent to completion and return its final summary.
820
+ Shared by `task` (one) and `Dispatch` (many in parallel). Hard-capped; never
821
+ raises (a sub-agent must not crash the parent turn)."""
799
822
  from drydock.agent import run as agent_run, AgentState, TurnDone
800
823
 
801
824
  sub_state = AgentState()
@@ -806,6 +829,9 @@ def tool_task(params: dict, config: dict) -> str:
806
829
  sub_config["max_tool_calls"] = 20
807
830
  sub_config.pop("_todo", None) # the sub-agent keeps no checklist of its own
808
831
  sub_config.pop("_plan_autocontinue", None)
832
+ # Own abort holder so parallel sub-agents don't clobber each other's (or the
833
+ # parent's) in-flight client/proc handles in the shared dict.
834
+ sub_config["_abort"] = {}
809
835
  steps = 0
810
836
  try:
811
837
  for ev in agent_run(prompt, sub_state, sub_config, _SUBAGENT_SYSTEM):
@@ -819,6 +845,54 @@ def tool_task(params: dict, config: dict) -> str:
819
845
  return f"[sub-agent finished {steps} step(s) with no summary]"
820
846
 
821
847
 
848
+ def tool_task(params: dict, config: dict) -> str:
849
+ """Spawn a read-only sub-agent with a FRESH context for a focused
850
+ exploration task, returning only its final summary. Keeps big searches out
851
+ of the main agent's context. Cannot recurse (no `task` tool) and cannot
852
+ write (no Write/Edit), so it can never corrupt the parent's work."""
853
+ prompt = (params.get("prompt") or params.get("description") or "").strip()
854
+ if not prompt:
855
+ return "Error: `task` needs a `prompt` describing what to investigate."
856
+ return _run_subagent(prompt, config)
857
+
858
+
859
+ def tool_dispatch(params: dict, config: dict) -> str:
860
+ """Run SEVERAL read-only sub-agents concurrently and return all summaries.
861
+ Each gets its own fresh context + Read/Glob/Grep/Bash (no recursion, no
862
+ writes). Use it to investigate independent questions at once."""
863
+ import concurrent.futures
864
+
865
+ raw = params.get("tasks") or params.get("agents") or []
866
+ if isinstance(raw, dict):
867
+ raw = [raw]
868
+ norm: list[dict] = []
869
+ for t in raw:
870
+ if isinstance(t, str) and t.strip():
871
+ norm.append({"prompt": t.strip(), "label": ""})
872
+ elif isinstance(t, dict):
873
+ p = (t.get("prompt") or t.get("description") or "").strip()
874
+ if p:
875
+ norm.append({"prompt": p, "label": (t.get("label") or t.get("description") or "").strip()})
876
+ if not norm:
877
+ return "Error: `Dispatch` needs a `tasks` list, each item a prompt (string) or {prompt, label}."
878
+ norm = norm[:6] # cap fan-out
879
+
880
+ results: list[str] = [""] * len(norm)
881
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(norm))) as ex:
882
+ futs = {ex.submit(_run_subagent, t["prompt"], config): i for i, t in enumerate(norm)}
883
+ for fut in concurrent.futures.as_completed(futs):
884
+ i = futs[fut]
885
+ try:
886
+ results[i] = fut.result()
887
+ except Exception as e: # noqa: BLE001
888
+ results[i] = f"[agent error: {e}]"
889
+ parts = [f"Dispatched {len(norm)} sub-agent(s) in parallel:"]
890
+ for i, t in enumerate(norm):
891
+ label = t["label"] or f"agent {i + 1}"
892
+ parts.append(f"\n=== {label} ===\n{results[i]}")
893
+ return "\n".join(parts)
894
+
895
+
822
896
  def _git_cwd(config: dict) -> str:
823
897
  return config.get("cwd") or os.getcwd()
824
898
 
@@ -940,6 +1014,7 @@ _TOOLS = [
940
1014
  ("Grep", tool_grep, True),
941
1015
  ("todo", tool_todo, False),
942
1016
  ("task", tool_task, True),
1017
+ ("Dispatch", tool_dispatch, True),
943
1018
  ("Knowledge", tool_knowledge, True),
944
1019
  ("WebSearch", tool_websearch, True),
945
1020
  ("WebFetch", tool_webfetch, True),
@@ -955,7 +1030,8 @@ def register_all():
955
1030
  func = {
956
1031
  "Read": tool_read, "Write": tool_write, "Edit": tool_edit,
957
1032
  "Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
958
- "todo": tool_todo, "task": tool_task, "Knowledge": tool_knowledge,
1033
+ "todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
1034
+ "Knowledge": tool_knowledge,
959
1035
  "WebSearch": tool_websearch, "WebFetch": tool_webfetch,
960
1036
  "GitStatus": tool_gitstatus, "GitDiff": tool_gitdiff,
961
1037
  "GitLog": tool_gitlog, "GitCommit": tool_gitcommit,
@@ -963,8 +1039,8 @@ def register_all():
963
1039
  # Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
964
1040
  # GitCommit writes a local, reversible commit).
965
1041
  read_only = name in (
966
- "Read", "Glob", "Grep", "task", "Knowledge", "WebSearch", "WebFetch",
967
- "GitStatus", "GitDiff", "GitLog",
1042
+ "Read", "Glob", "Grep", "task", "Dispatch", "Knowledge",
1043
+ "WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
968
1044
  )
969
1045
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
970
1046
 
@@ -153,7 +153,10 @@ class DrydockApp(App):
153
153
  self._abort_inflight()
154
154
  dropped = len(self._queue)
155
155
  self._queue.clear()
156
+ looping = self._repeat is not None
157
+ self._repeat = None # Esc/stop also ends an active /loop
156
158
  note = "⏹ stopped." + (f" (discarded {dropped} queued)" if dropped else "")
159
+ note += " loop ended." if looping else ""
157
160
  self._info(note)
158
161
  self._refresh_status()
159
162
 
@@ -251,6 +254,7 @@ class DrydockApp(App):
251
254
  # A plain dict survives run()'s dict(config) shallow copy by reference.
252
255
  self.config["_abort"] = {}
253
256
  self._queue: list[str] = [] # prompts submitted while a turn is running
257
+ self._repeat: dict | None = None # active /loop: {prompt, remaining, total}
254
258
  self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
255
259
  self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
256
260
  # Live "working" line state.
@@ -489,10 +493,13 @@ class DrydockApp(App):
489
493
  " /graphrag build/query a knowledge base from your docs\n"
490
494
  " /graphrag build <path> · /graphrag status · /graphrag clear\n"
491
495
  " /skills list your reusable /<name> skills\n"
496
+ " /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
492
497
  " /clear reset the conversation\n"
493
498
  " /quit exit\n"
494
499
  "Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
495
500
  )
501
+ elif cmd == "/loop":
502
+ self._cmd_loop(arg)
496
503
  elif cmd == "/skills":
497
504
  self._cmd_skills()
498
505
  elif cmd[1:] in self._skills:
@@ -500,6 +507,29 @@ class DrydockApp(App):
500
507
  else:
501
508
  self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
502
509
 
510
+ def _cmd_loop(self, arg: str) -> None:
511
+ """/loop <count> <prompt> — run <prompt> up to <count> times (1–50),
512
+ re-submitting after each turn finishes. Esc (or /stop) ends the loop."""
513
+ parts = arg.split(maxsplit=1)
514
+ if len(parts) < 2 or not parts[0].isdigit():
515
+ self._info(
516
+ "usage: /loop <count> <prompt> e.g. /loop 5 fix the next "
517
+ "failing test and run pytest\n(count 1–50; Esc stops the loop)"
518
+ )
519
+ return
520
+ count = max(1, min(int(parts[0]), 50))
521
+ prompt = parts[1].strip()
522
+ if not prompt:
523
+ self._info("usage: /loop <count> <prompt>")
524
+ return
525
+ if self._busy:
526
+ self._info("A turn is already running — stop it (Esc) before starting a loop.")
527
+ return
528
+ self._repeat = {"prompt": prompt, "remaining": count, "total": count}
529
+ self._info(f"↻ looping {count}× (Esc to stop): {prompt}")
530
+ self._mount(UserMessage(prompt))
531
+ self._begin(prompt)
532
+
503
533
  def _cmd_skills(self) -> None:
504
534
  if not self._skills:
505
535
  self._info(
@@ -780,6 +810,15 @@ class DrydockApp(App):
780
810
  if self._queue:
781
811
  self._begin(self._queue.pop(0))
782
812
  return
813
+ # /loop: re-run the prompt until the iteration count is exhausted (Esc/
814
+ # stop clears self._repeat). Queued user prompts take priority (above).
815
+ if self._repeat and self._repeat["remaining"] > 1 and not self._cancel.is_set():
816
+ self._repeat["remaining"] -= 1
817
+ done = self._repeat["total"] - self._repeat["remaining"] + 1
818
+ self._info(f"↻ loop iteration {done}/{self._repeat['total']}")
819
+ self._begin(self._repeat["prompt"])
820
+ return
821
+ self._repeat = None
783
822
  self._refresh_status()
784
823
  self.query_one("#prompt", PromptArea).focus()
785
824
 
@@ -153,7 +153,7 @@ class PromptHistory:
153
153
  # completes the prefix.
154
154
  SLASH_COMMANDS = [
155
155
  "/help", "/model", "/cwd", "/undo", "/back", "/stop", "/status",
156
- "/compact", "/graphrag", "/clear", "/quit",
156
+ "/compact", "/graphrag", "/skills", "/loop", "/clear", "/quit",
157
157
  ]
158
158
 
159
159
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.52
3
+ Version: 3.0.54
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -43,6 +43,7 @@ tests/test_config.py
43
43
  tests/test_config_migration.py
44
44
  tests/test_context_limit_config.py
45
45
  tests/test_detect.py
46
+ tests/test_dispatch.py
46
47
  tests/test_empty_response.py
47
48
  tests/test_failure_loop.py
48
49
  tests/test_first_run_setup.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.52"
10
+ version = "3.0.54"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,62 @@
1
+ """Multi-agent: the Dispatch tool fans out several read-only sub-agents in
2
+ parallel and returns all summaries. The sub-agent runner is mocked so tests are
3
+ deterministic and don't need the LLM."""
4
+ from __future__ import annotations
5
+
6
+ import time
7
+
8
+ import drydock.tools as T
9
+
10
+
11
+ def test_dispatch_runs_each_task_and_labels(monkeypatch):
12
+ monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"R:{p}")
13
+ out = T.tool_dispatch({"tasks": [
14
+ {"prompt": "find auth", "label": "auth"},
15
+ {"prompt": "find db"},
16
+ ]}, {})
17
+ assert "=== auth ===" in out and "R:find auth" in out
18
+ assert "=== agent 2 ===" in out and "R:find db" in out
19
+
20
+
21
+ def test_dispatch_accepts_string_tasks(monkeypatch):
22
+ monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"R:{p}")
23
+ out = T.tool_dispatch({"tasks": ["a", "b", "c"]}, {})
24
+ assert out.count("R:") == 3
25
+
26
+
27
+ def test_dispatch_needs_tasks():
28
+ assert "needs a `tasks`" in T.tool_dispatch({"tasks": []}, {})
29
+ assert "needs a `tasks`" in T.tool_dispatch({}, {})
30
+
31
+
32
+ def test_dispatch_caps_fanout(monkeypatch):
33
+ monkeypatch.setattr(T, "_run_subagent", lambda p, c: "x")
34
+ out = T.tool_dispatch({"tasks": [f"t{i}" for i in range(20)]}, {})
35
+ assert "Dispatched 6 sub-agent" in out # capped at 6
36
+
37
+
38
+ def test_dispatch_isolates_subagent_errors(monkeypatch):
39
+ def runner(p, c):
40
+ if "boom" in p:
41
+ raise RuntimeError("kaboom")
42
+ return f"R:{p}"
43
+ monkeypatch.setattr(T, "_run_subagent", runner)
44
+ out = T.tool_dispatch({"tasks": ["ok", "boom"]}, {})
45
+ assert "R:ok" in out and "agent error" in out # one fails, the other survives
46
+
47
+
48
+ def test_dispatch_runs_concurrently(monkeypatch):
49
+ # 4 tasks that each sleep 0.2s should finish in well under 0.8s (serial time)
50
+ def slow(p, c):
51
+ time.sleep(0.2)
52
+ return "done"
53
+ monkeypatch.setattr(T, "_run_subagent", slow)
54
+ t0 = time.monotonic()
55
+ T.tool_dispatch({"tasks": ["a", "b", "c", "d"]}, {})
56
+ assert time.monotonic() - t0 < 0.6 # parallel, not 0.8s serial
57
+
58
+
59
+ def test_task_still_works_via_shared_runner(monkeypatch):
60
+ monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"summary:{p}")
61
+ assert T.tool_task({"prompt": "explore X"}, {}) == "summary:explore X"
62
+ assert "needs a `prompt`" in T.tool_task({}, {})
@@ -348,3 +348,40 @@ def test_new_user_turn_clears_stale_plan():
348
348
  assert str(panel.render()) == "" # panel cleared
349
349
 
350
350
  asyncio.run(main())
351
+
352
+
353
+ def test_loop_command_iterates_and_stops():
354
+ """/loop <n> <prompt> re-runs the prompt n times, then clears; Esc ends it."""
355
+ async def main():
356
+ app = DrydockApp({"model": "gemma4", "provider": "vllm", "cwd": "/tmp"})
357
+ async with app.run_test() as pilot:
358
+ await pilot.pause()
359
+ begins = []
360
+ app._begin = lambda text: begins.append(text) # stub out the real agent
361
+
362
+ # bad usage → no loop
363
+ app._handle_slash("/loop notanumber")
364
+ assert app._repeat is None and not begins
365
+
366
+ # /loop 3 <prompt> → starts, first iteration begun
367
+ app._handle_slash("/loop 3 do the thing")
368
+ assert app._repeat and app._repeat["total"] == 3
369
+ assert begins == ["do the thing"]
370
+
371
+ # each finished turn re-begins until the count is exhausted
372
+ from drydock.tui.messages import AgentFinished
373
+ app.on_agent_finished(AgentFinished()) # iter 2
374
+ app.on_agent_finished(AgentFinished()) # iter 3
375
+ assert len(begins) == 3
376
+ app.on_agent_finished(AgentFinished()) # done → loop cleared
377
+ assert app._repeat is None
378
+ assert len(begins) == 3 # no 4th run
379
+
380
+ # Esc/stop ends an active loop
381
+ app._begin = lambda text: begins.append(text)
382
+ app._handle_slash("/loop 5 again")
383
+ app._busy = True
384
+ app.action_stop()
385
+ assert app._repeat is None
386
+
387
+ asyncio.run(main())
File without changes
File without changes
File without changes
File without changes