drydock-cli 3.0.17__tar.gz → 3.0.19__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 (50) hide show
  1. {drydock_cli-3.0.17/drydock_cli.egg-info → drydock_cli-3.0.19}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/__init__.py +1 -1
  3. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/agent.py +14 -0
  4. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/cli.py +6 -25
  5. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/compaction.py +50 -7
  6. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/app.py +36 -4
  7. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/widgets.py +11 -1
  8. {drydock_cli-3.0.17 → drydock_cli-3.0.19/drydock_cli.egg-info}/PKG-INFO +1 -1
  9. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/SOURCES.txt +2 -0
  10. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/pyproject.toml +1 -1
  11. drydock_cli-3.0.19/tests/test_cli_agents.py +34 -0
  12. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_compaction.py +34 -0
  13. drydock_cli-3.0.19/tests/test_stop.py +64 -0
  14. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_tui.py +7 -0
  15. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/LICENSE +0 -0
  16. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/NOTICE +0 -0
  17. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/README.md +0 -0
  18. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/__main__.py +0 -0
  19. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/bash_safety.py +0 -0
  20. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/config.py +0 -0
  21. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/detect.py +0 -0
  22. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/guards.py +0 -0
  23. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/loop_detect.py +0 -0
  24. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/providers.py +0 -0
  25. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tool_registry.py +0 -0
  26. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tools/__init__.py +0 -0
  27. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/__init__.py +0 -0
  28. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/approval.py +0 -0
  29. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/messages.py +0 -0
  30. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tuning.py +0 -0
  31. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/dependency_links.txt +0 -0
  32. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/entry_points.txt +0 -0
  33. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/requires.txt +0 -0
  34. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/top_level.txt +0 -0
  35. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/setup.cfg +0 -0
  36. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_approval.py +0 -0
  37. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_back_command.py +0 -0
  38. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_bash_safety.py +0 -0
  39. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_config.py +0 -0
  40. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_detect.py +0 -0
  41. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_guards_and_tools.py +0 -0
  42. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_hallucinated_tools.py +0 -0
  43. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_leaked_tool_call.py +0 -0
  44. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_loop_detect.py +0 -0
  45. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_plan_autocontinue.py +0 -0
  46. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_providers_unreachable.py +0 -0
  47. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_subagent.py +0 -0
  48. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_todo.py +0 -0
  49. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_tools_undo.py +0 -0
  50. {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_tuning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.17
3
+ Version: 3.0.19
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
@@ -7,4 +7,4 @@ try:
7
7
  __version__ = _version("drydock-cli")
8
8
  except PackageNotFoundError:
9
9
  # Running from source before install (e.g. PYTHONPATH dev runs).
10
- __version__ = "3.0.17"
10
+ __version__ = "3.0.19"
@@ -103,6 +103,13 @@ def run(
103
103
  # a call to anything else is refused — never executed. Keeps a read-only
104
104
  # sub-agent read-only and stops it from recursing into `task`.
105
105
  allow = config.get("tool_allowlist")
106
+ # User STOP signal (a threading.Event the TUI sets on Escape / "/stop").
107
+ # Checked only at SAFE points — top of the loop and after a turn's tool
108
+ # results are all appended — so a stop never leaves an assistant tool_call
109
+ # without its matching tool result (which would corrupt the history).
110
+ cancel = config.get("_cancel")
111
+ def _stopped() -> bool:
112
+ return cancel is not None and cancel.is_set()
106
113
  tool_call_count = 0
107
114
  session_has_edited = False
108
115
  leaked_call_retries = 0
@@ -111,6 +118,8 @@ def run(
111
118
  loop_tracker = LoopTracker()
112
119
 
113
120
  while state.turn_count < max_turns:
121
+ if _stopped():
122
+ break
114
123
  state.turn_count += 1
115
124
  run_iteration += 1
116
125
  assistant_turn: AssistantTurn | None = None
@@ -264,6 +273,11 @@ def run(
264
273
  "content": result,
265
274
  })
266
275
 
276
+ # Safe point (all tool results appended): honor a STOP requested while
277
+ # this turn's tools were running, before spending another LLM call.
278
+ if _stopped():
279
+ break
280
+
267
281
  # Real progress this turn — reset the consecutive stall-nudge counter so
268
282
  # a long, productive plan can run as far as it needs (the cap only
269
283
  # bounds back-to-back stalls).
@@ -59,27 +59,6 @@ def load_project_instructions() -> str:
59
59
  return ""
60
60
 
61
61
 
62
- def ensure_agents_md() -> None:
63
- """Auto-create AGENTS.md if no project instructions exist."""
64
- for name in ("AGENTS.md", "DRYDOCK.md", ".drydock.md", "CLAUDE.md"):
65
- if (Path.cwd() / name).exists():
66
- return
67
- (Path.cwd() / "AGENTS.md").write_text(
68
- "# Project Instructions\n\n"
69
- "DO NOT ask for confirmation. ACT IMMEDIATELY.\n"
70
- "If there is a PRD.md, implement it. If there is code, work on it.\n\n"
71
- "## Workflow\n"
72
- "1. Read requirements or explore existing code\n"
73
- "2. Create/edit files with Write or Edit\n"
74
- "3. Test with Bash\n"
75
- "4. Fix errors and verify\n\n"
76
- "## Rules\n"
77
- "- Use absolute imports for Python packages\n"
78
- "- Always create __init__.py and __main__.py\n"
79
- "- Test with python3 -m package_name\n"
80
- )
81
-
82
-
83
62
  # ── REPL ──────────────────────────────────────────────────────────────────
84
63
 
85
64
  def print_colored(text: str, color: str = "") -> None:
@@ -245,10 +224,12 @@ def main():
245
224
  "onboarding": onboarding,
246
225
  }
247
226
 
248
- ensure_agents_md()
249
- # Loaded after ensure_agents_md so the auto-created AGENTS.md is included.
250
- # The TUI reads this from config; CLI modes call load_project_instructions
251
- # directly (see run_interactive/run_oneshot).
227
+ # Load a user's OWN AGENTS.md/DRYDOCK.md as background context if present.
228
+ # Drydock never CREATES one: auto-writing an AGENTS.md littered every
229
+ # directory the user ran drydock in, and its "ACT IMMEDIATELY, implement
230
+ # the PRD" content fought the system prompt and risked turning a plain
231
+ # greeting into a runaway build. The TUI reads this from config; CLI modes
232
+ # call load_project_instructions directly (see run_interactive/run_oneshot).
252
233
  config["project_instructions"] = load_project_instructions()
253
234
 
254
235
  if args.prompt:
@@ -35,18 +35,52 @@ def is_context_length_error(err: str) -> bool:
35
35
 
36
36
 
37
37
  def estimate_tokens(messages: list) -> int:
38
- """Rough token estimate: chars / 3.5"""
38
+ """Rough token estimate: chars / 3.5.
39
+
40
+ CRUCIAL: a tool call's arguments live in a nested ``input`` dict — e.g. a
41
+ full-file Write puts the whole file in tool_calls[].input.content. Counting
42
+ only top-level strings missed that entirely, so a 40k-char Write read as ~0
43
+ tokens, compaction never fired, and the real request blew past the context
44
+ window. Count nested strings so the estimate reflects what's actually sent.
45
+ """
39
46
  total = 0
40
47
  for m in messages:
41
48
  content = m.get("content", "")
42
49
  if isinstance(content, str):
43
50
  total += len(content)
44
- for tc in m.get("tool_calls", []):
45
- if isinstance(tc, dict):
46
- for v in tc.values():
47
- if isinstance(v, str):
48
- total += len(v)
49
- return int(total / 3.5)
51
+ for tc in m.get("tool_calls", []) or []:
52
+ total += _count_chars(tc)
53
+ # /3.0, not /3.5: code is symbol-dense and tokenizes to MORE tokens than
54
+ # prose, so a conservative (slightly high) estimate keeps us off the wall.
55
+ return int(total / 3.0)
56
+
57
+
58
+ def _count_chars(v) -> int:
59
+ """Total length of every string nested in v (str / dict / list)."""
60
+ if isinstance(v, str):
61
+ return len(v)
62
+ if isinstance(v, dict):
63
+ return sum(_count_chars(x) for x in v.values())
64
+ if isinstance(v, (list, tuple)):
65
+ return sum(_count_chars(x) for x in v)
66
+ return 0
67
+
68
+
69
+ def _truncate_tool_call_args(messages: list, max_len: int) -> None:
70
+ """Shrink oversized string arguments inside OLD assistant tool calls (a
71
+ full-file Write/Edit is a top bloat source and nothing else truncates it).
72
+ The most recent tool-call message is left intact; truncating only the
73
+ argument *values* keeps each call's id, so tool-result pairing stays valid."""
74
+ last = max((i for i, m in enumerate(messages) if m.get("tool_calls")), default=-1)
75
+ for i, m in enumerate(messages):
76
+ if i == last:
77
+ continue
78
+ for tc in m.get("tool_calls") or []:
79
+ inp = tc.get("input") if isinstance(tc, dict) else None
80
+ if isinstance(inp, dict):
81
+ for k, v in inp.items():
82
+ if isinstance(v, str) and len(v) > max_len:
83
+ inp[k] = v[: max_len // 2] + "\n[... arg truncated ...]"
50
84
 
51
85
 
52
86
  def compact(messages: list, context_limit: int = 131072) -> list:
@@ -92,6 +126,10 @@ def compact(messages: list, context_limit: int = 131072) -> list:
92
126
  if current <= target:
93
127
  break
94
128
 
129
+ # Pass 3: shrink big old tool-call arguments (full-file Write/Edit bodies).
130
+ if estimate_tokens(messages) > target:
131
+ _truncate_tool_call_args(messages, max_len=1500)
132
+
95
133
  return messages
96
134
 
97
135
 
@@ -135,6 +173,11 @@ def emergency_compact(messages: list, context_limit: int = 131072) -> list:
135
173
  if isinstance(content, str) and len(content) > 500:
136
174
  messages[i]["content"] = content[:300] + "\n[... truncated ...]"
137
175
 
176
+ # Pass 4: shrink big tool-CALL arguments — a full-file Write/Edit puts the
177
+ # whole file in tool_calls[].input and nothing above touches it. This was
178
+ # THE leak that let the real request stay over the window after compaction.
179
+ _truncate_tool_call_args(messages, max_len=300)
180
+
138
181
  return messages
139
182
 
140
183
 
@@ -8,6 +8,7 @@ messages (see tui/messages.py). Nautical theme, original branding.
8
8
  from __future__ import annotations
9
9
 
10
10
  import random
11
+ import threading
11
12
  import time
12
13
 
13
14
  from textual import work
@@ -119,6 +120,10 @@ class DrydockApp(App):
119
120
  BINDINGS = [
120
121
  Binding("ctrl+c", "copy_selection", "Copy", priority=True),
121
122
  Binding("ctrl+d", "quit", "Quit", priority=True),
123
+ # Escape stops a running turn (the agent ends cleanly, session kept) —
124
+ # the way out of a runaway task without quitting the whole app. "/stop"
125
+ # does the same. priority so it isn't swallowed by the focused prompt.
126
+ Binding("escape", "stop", "Stop the running turn", priority=True),
122
127
  Binding("ctrl+o", "toggle_tools", "Expand/collapse details"),
123
128
  # Scroll the transcript from the keyboard (focus stays on the prompt,
124
129
  # and SSH sessions often don't forward the mouse wheel). priority=True
@@ -129,6 +134,21 @@ class DrydockApp(App):
129
134
  Binding("ctrl+end", "scroll_bottom", "Bottom", priority=True, show=False),
130
135
  ]
131
136
 
137
+ def action_stop(self) -> None:
138
+ """STOP the running turn: signal the agent loop to end at its next safe
139
+ point, drop any queued prompts, and hand control back. The session
140
+ (history, files) is preserved — this is not a quit."""
141
+ if not self._busy:
142
+ return
143
+ self._cancel.set()
144
+ dropped = len(self._queue)
145
+ self._queue.clear()
146
+ note = "⏹ stopping after the current step…"
147
+ if dropped:
148
+ note += f" (discarded {dropped} queued)"
149
+ self._info(note)
150
+ self._refresh_status()
151
+
132
152
  def action_scroll_up(self) -> None:
133
153
  self._scroll.scroll_page_up()
134
154
 
@@ -185,6 +205,11 @@ class DrydockApp(App):
185
205
  self._current_assistant: AssistantMessage | None = None
186
206
  self._last_card: ToolCard | None = None
187
207
  self._busy = False
208
+ # STOP signal: Escape / "/stop" sets it; the agent loop checks it at safe
209
+ # points and ends the turn cleanly (session preserved). Lives in config
210
+ # so run() — and any sub-agent that copies config — can see it.
211
+ self._cancel = threading.Event()
212
+ self.config["_cancel"] = self._cancel
188
213
  self._queue: list[str] = [] # prompts submitted while a turn is running
189
214
  self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
190
215
  self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
@@ -210,9 +235,9 @@ class DrydockApp(App):
210
235
  return "deny"
211
236
 
212
237
  def _build_system(self, model: str | None) -> str:
213
- # The TUI must honor AGENTS.md/DRYDOCK.md like the CLI does — it's the
214
- # primary surface, and project instructions carry the user's tool/style
215
- # conventions. cli.main() loads them into config after ensure_agents_md.
238
+ # The TUI honors a user's OWN AGENTS.md/DRYDOCK.md like the CLI does —
239
+ # cli.main() loads it into config["project_instructions"] as background
240
+ # context (drydock never auto-creates one).
216
241
  return system_prompt_for_model(model) + self.config.get("project_instructions", "")
217
242
 
218
243
  # ── layout ────────────────────────────────────────────────────────────
@@ -318,6 +343,7 @@ class DrydockApp(App):
318
343
  def _begin(self, text: str) -> None:
319
344
  """Start an agent turn for an already-displayed user prompt."""
320
345
  self._current_assistant = None
346
+ self._cancel.clear() # fresh turn — clear any prior STOP
321
347
  self._busy = True
322
348
  self._work_start = time.monotonic()
323
349
  self._work_word = random.choice(_WORKING_WORDS)
@@ -336,6 +362,11 @@ class DrydockApp(App):
336
362
  arg = parts[1].strip() if len(parts) > 1 else ""
337
363
  if cmd in ("/quit", "/exit", "/q"):
338
364
  self.exit()
365
+ elif cmd in ("/stop", "/cancel"):
366
+ if self._busy:
367
+ self.action_stop()
368
+ else:
369
+ self._info("Nothing is running.")
339
370
  elif cmd == "/clear":
340
371
  self.state = AgentState()
341
372
  self._scroll.remove_children()
@@ -377,10 +408,11 @@ class DrydockApp(App):
377
408
  " /cwd [path] show or change the working directory\n"
378
409
  " /undo revert the last file write/edit\n"
379
410
  " /back rewind the last turn from the model's context\n"
411
+ " /stop stop the running turn (or press Esc)\n"
380
412
  " /status session model, cwd, turns, tokens\n"
381
413
  " /clear reset the conversation\n"
382
414
  " /quit exit\n"
383
- "Type a task and press Enter. ↑/↓ recall history · Ctrl+O expands tools."
415
+ "Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
384
416
  )
385
417
  else:
386
418
  self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
@@ -16,12 +16,22 @@ _FAILURE_PREFIXES = ("Error", "REFUSED")
16
16
  # "[NOTE: …]" lines (see loop_detect.annotate). Strip them before judging
17
17
  # success, or a failed Edit behind a "[NOTE: write #9…]" reads as ✓.
18
18
  _LEADING_NOTES = re.compile(r"^\s*(?:\[NOTE:[^\]]*\]\s*)+")
19
+ # Bash appends "[exit code: N]" only when a command FAILED (non-zero return).
20
+ # Without this, a failed shell command (e.g. `command not found`, a compile
21
+ # error, a failing test) renders as a green ✓ — which hides exactly the kind
22
+ # of failure the operator relies on the TUI to surface.
23
+ _BASH_EXIT = re.compile(r"\[exit code: (\d+)\]")
19
24
 
20
25
 
21
26
  def result_is_ok(result: str) -> bool:
22
27
  """Whether a tool result should render as success (✓) vs failure (✗)."""
23
28
  cleaned = _LEADING_NOTES.sub("", result or "")
24
- return not cleaned.lstrip().startswith(_FAILURE_PREFIXES)
29
+ if cleaned.lstrip().startswith(_FAILURE_PREFIXES):
30
+ return False
31
+ m = _BASH_EXIT.search(cleaned)
32
+ if m and m.group(1) != "0":
33
+ return False
34
+ return True
25
35
 
26
36
 
27
37
  _TOOL_BODY_MAX = 8000
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.17
3
+ Version: 3.0.19
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
@@ -30,6 +30,7 @@ drydock_cli.egg-info/top_level.txt
30
30
  tests/test_approval.py
31
31
  tests/test_back_command.py
32
32
  tests/test_bash_safety.py
33
+ tests/test_cli_agents.py
33
34
  tests/test_compaction.py
34
35
  tests/test_config.py
35
36
  tests/test_detect.py
@@ -39,6 +40,7 @@ tests/test_leaked_tool_call.py
39
40
  tests/test_loop_detect.py
40
41
  tests/test_plan_autocontinue.py
41
42
  tests/test_providers_unreachable.py
43
+ tests/test_stop.py
42
44
  tests/test_subagent.py
43
45
  tests/test_todo.py
44
46
  tests/test_tools_undo.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.17"
10
+ version = "3.0.19"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.12"
@@ -0,0 +1,34 @@
1
+ """Drydock must LOAD a user's own AGENTS.md/DRYDOCK.md as background context,
2
+ but must NEVER auto-create one (it littered every working directory and its
3
+ 'ACT IMMEDIATELY, implement the PRD' content fought the system prompt)."""
4
+ from __future__ import annotations
5
+
6
+ import os
7
+
8
+ from drydock import cli
9
+
10
+
11
+ def _in_dir(tmp_path, fn):
12
+ cwd = os.getcwd()
13
+ os.chdir(tmp_path)
14
+ try:
15
+ return fn()
16
+ finally:
17
+ os.chdir(cwd)
18
+
19
+
20
+ def test_no_agents_md_is_created(tmp_path):
21
+ # An empty project must stay empty after loading instructions.
22
+ text = _in_dir(tmp_path, cli.load_project_instructions)
23
+ assert text == ""
24
+ assert list(tmp_path.iterdir()) == [] # nothing written
25
+ assert not hasattr(cli, "ensure_agents_md") # the auto-creator is gone
26
+
27
+
28
+ def test_existing_agents_md_loaded_as_background(tmp_path):
29
+ (tmp_path / "AGENTS.md").write_text("Use 4-space indent. Prefer pathlib.")
30
+ text = _in_dir(tmp_path, cli.load_project_instructions)
31
+ assert "Use 4-space indent" in text
32
+ # Framed as background context, NOT a command to act now.
33
+ assert "background context" in text.lower()
34
+ assert "not a" in text.lower() and "act now" in text.lower()
@@ -15,6 +15,40 @@ from drydock.compaction import (
15
15
  )
16
16
 
17
17
 
18
+ def test_estimate_counts_tool_call_input_args():
19
+ # A full-file Write hides its bulk in tool_calls[].input.content. The
20
+ # estimate MUST see it (this was the bug: it read as ~0 tokens).
21
+ big = "x" * 40000
22
+ msgs = [{"role": "assistant", "content": "",
23
+ "tool_calls": [{"id": "1", "name": "Write",
24
+ "input": {"file_path": "a.py", "content": big}}]}]
25
+ assert estimate_tokens(msgs) > 10000 # ~40000/3.0, not ~0
26
+
27
+
28
+ def test_emergency_compact_shrinks_big_write_arg_below_limit():
29
+ # Reproduce the operator's crash shape: history dominated by an OLD
30
+ # full-file Write arg. Emergency compaction must get the estimate under the
31
+ # real 64k window (it returned the SAME oversized request before).
32
+ limit = 65536
33
+ big = "y" * 250000 # ~83k est tokens — over the window
34
+ msgs = [
35
+ {"role": "user", "content": "refactor cli.py"},
36
+ {"role": "assistant", "content": "",
37
+ "tool_calls": [{"id": "1", "name": "Write",
38
+ "input": {"file_path": "cli.py", "content": big}}]},
39
+ {"role": "tool", "tool_call_id": "1", "name": "Write", "content": "ok"},
40
+ {"role": "user", "content": "now run tests"},
41
+ {"role": "assistant", "content": "",
42
+ "tool_calls": [{"id": "2", "name": "Bash", "input": {"command": "pytest"}}]},
43
+ {"role": "tool", "tool_call_id": "2", "name": "Bash", "content": "passed"},
44
+ ]
45
+ assert estimate_tokens(msgs) > limit
46
+ emergency_compact(msgs, limit)
47
+ assert estimate_tokens(msgs) < limit # actually under the window now
48
+ # The recent Bash tool_call (last) is preserved intact.
49
+ assert msgs[4]["tool_calls"][0]["input"]["command"] == "pytest"
50
+
51
+
18
52
  def test_is_context_length_error_matches_provider_phrasings():
19
53
  for msg in [
20
54
  "This model's maximum context length is 131072 tokens",
@@ -0,0 +1,64 @@
1
+ """STOP/interrupt: a threading.Event in config["_cancel"] lets the TUI halt a
2
+ running turn at a safe point. The agent must (a) not start a turn when stop is
3
+ already set, and (b) end after the current turn's tools rather than looping —
4
+ without leaving an assistant tool_call missing its tool result."""
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+
9
+ import drydock.agent as agent_mod
10
+ from drydock.agent import AgentState, run
11
+ from drydock.providers import AssistantTurn
12
+
13
+
14
+ def test_no_turn_when_already_stopped(monkeypatch):
15
+ calls = {"n": 0}
16
+
17
+ def fake_stream(**kw):
18
+ calls["n"] += 1
19
+ yield AssistantTurn("hi", [], 1, 1)
20
+
21
+ monkeypatch.setattr(agent_mod, "stream", fake_stream)
22
+ ev = threading.Event()
23
+ ev.set()
24
+ st = AgentState()
25
+ list(run("go", st, {"model": "m", "_cancel": ev}, "sys"))
26
+ assert calls["n"] == 0 # loop broke before any model call
27
+
28
+
29
+ def test_stop_ends_after_current_turn(monkeypatch):
30
+ # The model would loop forever (always returns a tool call); a stop set
31
+ # during the first turn must end the run after that turn's tools.
32
+ calls = {"n": 0}
33
+ ev = threading.Event()
34
+
35
+ def fake_stream(**kw):
36
+ calls["n"] += 1
37
+ ev.set() # user hits STOP while this turn runs
38
+ yield AssistantTurn(
39
+ "", [{"id": str(calls["n"]), "name": "Read", "input": {"file_path": "nope"}}], 1, 1
40
+ )
41
+
42
+ monkeypatch.setattr(agent_mod, "stream", fake_stream)
43
+ st = AgentState()
44
+ list(run("go", st, {"model": "m", "_cancel": ev}, "sys"))
45
+ assert calls["n"] == 1 # exactly one turn, then stopped
46
+
47
+ # History is still valid: every assistant tool_call has a matching tool msg.
48
+ call_ids = [
49
+ tc["id"]
50
+ for msg in st.messages
51
+ if msg.get("role") == "assistant"
52
+ for tc in (msg.get("tool_calls") or [])
53
+ ]
54
+ tool_ids = [m["tool_call_id"] for m in st.messages if m["role"] == "tool"]
55
+ assert sorted(call_ids) == sorted(tool_ids)
56
+
57
+
58
+ def test_no_cancel_event_runs_normally(monkeypatch):
59
+ # Absent a _cancel event, behaviour is unchanged.
60
+ seq = [AssistantTurn("done", [], 1, 1)]
61
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
62
+ st = AgentState()
63
+ list(run("go", st, {"model": "m"}, "sys"))
64
+ assert any(m.get("role") == "assistant" for m in st.messages)
@@ -123,6 +123,13 @@ def test_result_is_ok_marks_failures():
123
123
  assert not result_is_ok("Error: nope")
124
124
  assert not result_is_ok("REFUSED: this command reformats a filesystem...")
125
125
  assert not result_is_ok(" Error: leading whitespace still counts")
126
+ # A failed shell command (Bash appends "[exit code: N]" only on failure)
127
+ # must render ✗, not a green ✓ — otherwise the TUI hides real failures.
128
+ assert not result_is_ok("bash: sqlite3: command not found\n[exit code: 127]")
129
+ assert not result_is_ok("test.c:1: error\n[exit code: 1]")
130
+ # Successful command output (no exit-code marker) stays ✓, even if it
131
+ # happens to mention the word 'error' in normal output.
132
+ assert result_is_ok("0 errors, 0 warnings\nBuild succeeded")
126
133
 
127
134
 
128
135
  def test_summarize_inputs_prefers_meaningful_keys():
File without changes
File without changes
File without changes
File without changes