hkblockchaintechnology commited on
Commit
03c1ae9
·
verified ·
1 Parent(s): 42ee5cf

Tests: watchdog, audit and the AT-SPI guard

Browse files
tests/conftest.py CHANGED
@@ -20,3 +20,19 @@ def require_ocr() -> None:
20
  available, detail = detector_available("ocr")
21
  if not available:
22
  pytest.skip(f"no OCR engine installed: {detail}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  available, detail = detector_available("ocr")
21
  if not available:
22
  pytest.skip(f"no OCR engine installed: {detail}")
23
+
24
+
25
+ def require_atspi() -> None:
26
+ """Skip the calling test unless the AT-SPI reader can actually be built.
27
+
28
+ The same trap as `require_ocr`, one layer down. `make_detector("atspi")` is
29
+ right to raise when PyGObject is missing - but a test that calls it on a
30
+ machine without the typelib *fails* where it should skip, and the failure
31
+ reads like a harness defect. Ask the backend that needs the typelib, never a
32
+ name that resolves anyway.
33
+ """
34
+ from tiny_computer_agent.accessibility import accessibility_available
35
+
36
+ available, detail = accessibility_available("atspi")
37
+ if not available:
38
+ pytest.skip(f"no AT-SPI reader on this machine: {detail}")
tests/test_accessibility.py CHANGED
@@ -8,6 +8,8 @@ one test that drives actual AT-SPI is opt-in, like the live-desktop tests.
8
  import os
9
 
10
  import pytest
 
 
11
  from PIL import Image
12
 
13
  import platform
@@ -241,11 +243,12 @@ def test_the_auto_detector_still_defaults_to_ocr_then_grid():
241
 
242
 
243
  def test_the_new_detector_names_are_reachable():
244
- assert type(make_detector("atspi")).__name__ == "AtspiDetector"
245
  assert type(make_detector("best")).__name__ == "AutoDetector"
 
 
 
246
  ok, detail = detector_available("atspi")
247
  assert isinstance(ok, bool) and detail
248
- assert TEXT_ROLES and "label" in TEXT_ROLES
249
 
250
 
251
  # -- the Windows backend: its mapping, exercised without Windows ---------------
@@ -331,12 +334,17 @@ def test_the_uia_reader_refuses_rather_than_returning_an_empty_index():
331
  UiaReader().read()
332
 
333
 
334
- def test_the_detector_names_cover_both_backends():
335
- assert type(make_detector("atspi")).__name__ == "AtspiDetector"
336
  ok, detail = detector_available("uia")
337
  assert isinstance(ok, bool) and detail
338
 
339
 
 
 
 
 
 
340
  def test_a_provider_that_throws_is_reported_as_unavailable():
341
  def broken():
342
  raise RuntimeError("COM is not initialised")
 
8
  import os
9
 
10
  import pytest
11
+
12
+ from conftest import require_atspi
13
  from PIL import Image
14
 
15
  import platform
 
243
 
244
 
245
  def test_the_new_detector_names_are_reachable():
 
246
  assert type(make_detector("best")).__name__ == "AutoDetector"
247
+ assert TEXT_ROLES and "label" in TEXT_ROLES
248
+ require_atspi() # the atspi name needs the typelib to build
249
+ assert type(make_detector("atspi")).__name__ == "AtspiDetector"
250
  ok, detail = detector_available("atspi")
251
  assert isinstance(ok, bool) and detail
 
252
 
253
 
254
  # -- the Windows backend: its mapping, exercised without Windows ---------------
 
334
  UiaReader().read()
335
 
336
 
337
+ def test_the_windows_detector_name_is_reachable_without_windows():
338
+ """The Windows backend's *mapping* is exercised anywhere; its tree is not."""
339
  ok, detail = detector_available("uia")
340
  assert isinstance(ok, bool) and detail
341
 
342
 
343
+ def test_the_detector_names_cover_both_backends():
344
+ require_atspi()
345
+ assert type(make_detector("atspi")).__name__ == "AtspiDetector"
346
+
347
+
348
  def test_a_provider_that_throws_is_reported_as_unavailable():
349
  def broken():
350
  raise RuntimeError("COM is not initialised")
tests/test_api_surface.py CHANGED
@@ -21,7 +21,7 @@ def test_no_public_callable_in_a_module_is_missing_from_all():
21
  modules = ["actions", "act", "observe", "perception", "brain", "models", "loop",
22
  "safety", "desktop", "demo", "trace",
23
  # 2.0: the operator layer, all of which must stay importable from the root
24
- "diff", "gates", "runner", "skills"]
25
  unexported = []
26
  for name in modules:
27
  module = importlib.import_module(f"tiny_computer_agent.{name}")
 
21
  modules = ["actions", "act", "observe", "perception", "brain", "models", "loop",
22
  "safety", "desktop", "demo", "trace",
23
  # 2.0: the operator layer, all of which must stay importable from the root
24
+ "diff", "gates", "runner", "skills", "watchdog", "audit"]
25
  unexported = []
26
  for name in modules:
27
  module = importlib.import_module(f"tiny_computer_agent.{name}")
tests/test_atspi_guard.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The AT-SPI guard must actually skip when there is no typelib.
2
+
3
+ Same bug, one layer down from the OCR guard: `make_detector("atspi")` correctly
4
+ raises without PyGObject, so two tests failed on a machine that has no AT-SPI
5
+ reader - and a failing test reads like a harness defect rather than a missing
6
+ system package. The guard now asks the backend that needs the typelib, and here
7
+ is the proof that it skips, passes, and asks for the right thing.
8
+ """
9
+
10
+ import pytest
11
+
12
+ from conftest import require_atspi
13
+
14
+ import tiny_computer_agent.accessibility as accessibility
15
+
16
+
17
+ def test_the_guard_skips_when_the_typelib_is_missing(monkeypatch):
18
+ monkeypatch.setattr(accessibility, "accessibility_available",
19
+ lambda name="atspi": (False, "simulated: no PyGObject"))
20
+ with pytest.raises(pytest.skip.Exception):
21
+ require_atspi()
22
+
23
+
24
+ def test_the_guard_passes_when_the_reader_can_be_built(monkeypatch):
25
+ monkeypatch.setattr(accessibility, "accessibility_available",
26
+ lambda name="atspi": (True, "simulated: AT-SPI reader ready"))
27
+ require_atspi() # must not raise
28
+
29
+
30
+ def test_the_guard_asks_for_atspi_and_not_a_name_that_always_resolves(monkeypatch):
31
+ asked = []
32
+
33
+ def record(name="atspi"):
34
+ asked.append(name)
35
+ return True, "recorded"
36
+
37
+ monkeypatch.setattr(accessibility, "accessibility_available", record)
38
+ require_atspi()
39
+ assert asked == ["atspi"]
40
+
41
+
42
+ def test_uia_is_reported_unavailable_on_linux_and_that_is_not_a_failure():
43
+ """Why the Windows test had to be split rather than guarded as a whole."""
44
+ available, detail = accessibility.accessibility_available("uia")
45
+ assert available is False, "UI Automation is Windows-only"
46
+ assert detail, "an unavailable backend still has to say why"
tests/test_audit.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The record must be checkable, and must say when it is not.
2
+
3
+ A run's JSON can be edited by whoever would prefer a better result. These tests write a real
4
+ log from a real episode, then do the three things a dishonest reader would try - edit a line,
5
+ delete a line, reorder them - and require the verifier to name the entry at fault.
6
+ """
7
+
8
+ import json
9
+
10
+ from tiny_computer_agent import (
11
+ AgentLoop, AuditLog, Element, FakeScreen, MockBrain, observation_digest, verify_audit,
12
+ )
13
+
14
+
15
+ def _screen():
16
+ return FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
17
+
18
+
19
+ def _brain():
20
+ return MockBrain([{"kind": "click", "label": "Save"},
21
+ {"kind": "done", "reason": "file saved"}])
22
+
23
+
24
+ def _run(tmp_path, name="audit.jsonl", **kwargs):
25
+ log = AuditLog(tmp_path / name)
26
+ loop = AgentLoop(screen=_screen(), brain=_brain(), audit=log, **kwargs)
27
+ return loop.run("click 'Save' to save the document"), log
28
+
29
+
30
+ # -- writing ------------------------------------------------------------------- #
31
+
32
+ def test_a_run_writes_one_chained_entry_per_step_plus_a_closing_one(tmp_path):
33
+ episode, log = _run(tmp_path)
34
+ entries = log.entries()
35
+ assert [e.kind for e in entries] == ["step", "step", "episode"]
36
+ assert [e.index for e in entries] == [0, 1, 2]
37
+ assert entries[0].prev == "", "the first entry follows nothing"
38
+ assert entries[1].prev == entries[0].digest
39
+ assert all(e.digest and len(e.digest) == 64 for e in entries)
40
+ assert "click label='Save'" in entries[0].action
41
+
42
+
43
+ def test_the_chain_verifies_after_a_real_run(tmp_path):
44
+ _run(tmp_path)
45
+ ok, detail = verify_audit(tmp_path / "audit.jsonl")
46
+ assert ok, detail
47
+ assert detail == "3 entries, chain intact"
48
+
49
+
50
+ def test_the_closing_entry_carries_the_verdict_not_the_brains_word(tmp_path):
51
+ episode, log = _run(tmp_path)
52
+ assert episode.success
53
+ closing = log.entries()[-1]
54
+ assert closing.kind == "episode"
55
+ assert closing.ok is True
56
+ assert "success" in closing.outcome and episode.stop_reason in closing.outcome
57
+
58
+
59
+ def test_render_counts_the_steps_and_the_chain(tmp_path):
60
+ _, log = _run(tmp_path)
61
+ line = log.render()
62
+ assert "2 steps" in line
63
+ assert "chain intact" in line
64
+
65
+
66
+ def test_a_run_with_no_audit_writes_nothing(tmp_path):
67
+ episode = AgentLoop(screen=_screen(), brain=_brain()).run("save")
68
+ assert episode.success
69
+ assert list(tmp_path.iterdir()) == []
70
+
71
+
72
+ def test_resuming_a_log_continues_its_chain(tmp_path):
73
+ """A runner that is killed and restarted must not start a second, unrelated log."""
74
+ _run(tmp_path)
75
+ episode, log = _run(tmp_path) # same path, fresh AuditLog
76
+ assert episode.success
77
+ entries = log.entries()
78
+ assert [e.index for e in entries] == [0, 1, 2, 3, 4, 5]
79
+ assert entries[3].prev == entries[2].digest
80
+ assert verify_audit(tmp_path / "audit.jsonl")[0] is True
81
+
82
+
83
+ # -- tampering ----------------------------------------------------------------- #
84
+
85
+ def _rewrite(tmp_path, lines, name="audit.jsonl"):
86
+ (tmp_path / name).write_text("\n".join(lines) + "\n", encoding="utf-8")
87
+
88
+
89
+ def test_an_edited_entry_is_caught_and_named(tmp_path):
90
+ _run(tmp_path)
91
+ path = tmp_path / "audit.jsonl"
92
+ lines = path.read_text(encoding="utf-8").splitlines()
93
+ entry = json.loads(lines[1])
94
+ entry["outcome"] = "clicked without incident" # a nicer story
95
+ lines[1] = json.dumps(entry, sort_keys=True, separators=(",", ":"))
96
+ _rewrite(tmp_path, lines)
97
+
98
+ ok, detail = verify_audit(path)
99
+ assert ok is False
100
+ assert "entry 2" in detail and "was edited" in detail
101
+
102
+
103
+ def test_a_deleted_line_is_caught(tmp_path):
104
+ _run(tmp_path)
105
+ path = tmp_path / "audit.jsonl"
106
+ lines = path.read_text(encoding="utf-8").splitlines()
107
+ _rewrite(tmp_path, [lines[0], lines[2]])
108
+ ok, detail = verify_audit(path)
109
+ assert ok is False
110
+ assert "entry 2" in detail and "out of order" in detail
111
+
112
+
113
+ def test_reordered_lines_are_caught(tmp_path):
114
+ _run(tmp_path)
115
+ path = tmp_path / "audit.jsonl"
116
+ lines = path.read_text(encoding="utf-8").splitlines()
117
+ _rewrite(tmp_path, [lines[1], lines[0], lines[2]])
118
+ ok, detail = verify_audit(path)
119
+ assert ok is False
120
+ assert "entry 1" in detail
121
+
122
+
123
+ def test_a_missing_log_is_not_reported_as_fine(tmp_path):
124
+ ok, detail = verify_audit(tmp_path / "nothing-here.jsonl")
125
+ assert ok is False and "no audit log" in detail
126
+
127
+
128
+ def test_an_empty_log_is_not_reported_as_fine(tmp_path):
129
+ path = tmp_path / "empty.jsonl"
130
+ path.write_text("")
131
+ ok, detail = verify_audit(path)
132
+ assert ok is False and "empty" in detail
133
+
134
+
135
+ # -- the screen digest --------------------------------------------------------- #
136
+
137
+ def test_the_digest_identifies_the_index_not_the_pixels(tmp_path):
138
+ same = FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)]).capture()
139
+ again = FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)]).capture()
140
+ moved = FakeScreen([(Element("Save", x=400, y=120, width=80, height=24),)]).capture()
141
+ renamed = FakeScreen([(Element("Cancel", x=320, y=120, width=80, height=24),)]).capture()
142
+
143
+ assert observation_digest(same) == observation_digest(again)
144
+ assert observation_digest(same) != observation_digest(moved)
145
+ assert observation_digest(same) != observation_digest(renamed)
146
+ assert len(observation_digest(same)) == 16
tests/test_cli.py CHANGED
@@ -3,6 +3,7 @@ import json
3
  import pytest
4
 
5
  from tiny_computer_agent import __main__ as cli
 
6
 
7
 
8
  def test_demo_runs_dry_run_by_default(capsys):
@@ -153,3 +154,61 @@ def test_batch_rejects_tasks_that_are_not_tasks(tmp_path, capsys):
153
  missing = tmp_path / "absent.json"
154
  assert cli.main(["batch", str(missing)]) == 2
155
  assert "cannot read" in capsys.readouterr().err
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import pytest
4
 
5
  from tiny_computer_agent import __main__ as cli
6
+ from tiny_computer_agent import verify_audit
7
 
8
 
9
  def test_demo_runs_dry_run_by_default(capsys):
 
154
  missing = tmp_path / "absent.json"
155
  assert cli.main(["batch", str(missing)]) == 2
156
  assert "cannot read" in capsys.readouterr().err
157
+
158
+
159
+ # -- 2.0: the operator's gate, and a record of the run that can be checked ------- #
160
+
161
+ SCRIPT = '[{"kind": "click", "label": "Save"}, {"kind": "done", "reason": "saved"}]'
162
+
163
+
164
+ def test_the_audit_flag_writes_a_chain_that_verifies(tmp_path, capsys):
165
+ path = tmp_path / "run.jsonl"
166
+ assert cli.main(["--image", "example", "--script", SCRIPT, "--audit", str(path)]) == 0
167
+ out = capsys.readouterr().out
168
+ assert "recording this run to" in out
169
+ assert "audit:" in out and "chain intact" in out
170
+ ok, detail = verify_audit(path)
171
+ assert ok, detail
172
+
173
+
174
+ def test_verify_audit_accepts_an_intact_chain(tmp_path, capsys):
175
+ path = tmp_path / "run.jsonl"
176
+ cli.main(["--image", "example", "--script", SCRIPT, "--audit", str(path)])
177
+ assert cli.main(["--verify-audit", str(path)]) == 0
178
+ assert "ok" in capsys.readouterr().out
179
+
180
+
181
+ def test_verify_audit_fails_loudly_on_a_tampered_chain(tmp_path, capsys):
182
+ path = tmp_path / "run.jsonl"
183
+ cli.main(["--image", "example", "--script", SCRIPT, "--audit", str(path)])
184
+ lines = path.read_text(encoding="utf-8").splitlines()
185
+ entry = json.loads(lines[0])
186
+ entry["outcome"] = "nothing happened" # a nicer story
187
+ lines[0] = json.dumps(entry, sort_keys=True, separators=(",", ":"))
188
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
189
+
190
+ assert cli.main(["--verify-audit", str(path)]) == 1
191
+ out = capsys.readouterr().out
192
+ assert "FAIL" in out and "entry 1" in out
193
+
194
+
195
+ def test_a_bad_gate_spec_is_a_usage_error(capsys):
196
+ assert cli.main(["--gate", "yes-please"]) == 2
197
+ assert "bad --gate" in capsys.readouterr().err
198
+
199
+
200
+ def test_the_read_only_gate_refuses_to_change_anything(capsys):
201
+ """The gate is the operator's yes; read-only means the answer is no to any change."""
202
+ code = cli.main(["--image", "example", "--script",
203
+ '[{"kind": "click", "label": "Save"}]', "--gate", "read-only"])
204
+ out = capsys.readouterr().out
205
+ assert code == 1, "a refused action is a failed episode, not a success"
206
+ assert "gate: read-only" in out
207
+ assert "operator refused" in out and "read-only gate" in out
208
+
209
+
210
+ def test_the_labels_gate_lets_a_whitelisted_click_through(capsys):
211
+ code = cli.main(["--image", "example", "--script", SCRIPT, "--gate", "labels:Save"])
212
+ out = capsys.readouterr().out
213
+ assert code == 0, out
214
+ assert "is whitelisted" in out or "would click" in out
tests/test_gates.py CHANGED
@@ -1,8 +1,10 @@
 
 
1
  """A refusal from the operator is a step, not a traceback."""
2
 
3
  from tiny_computer_agent import (
4
  Action, AgentLoop, AllOf, AllowAll, AllowOnlyLabels, AnyOf, Approval, DenyAll, Element,
5
- FakeScreen, InteractiveGate, MockBrain, Policy, mutating,
6
  )
7
 
8
 
@@ -95,3 +97,38 @@ def test_a_gate_refusal_stops_the_episode_and_is_recorded():
95
  assert episode.steps[0].error is not None and episode.steps[0].error.startswith("not approved")
96
  assert episode.steps[0].result is None, "nothing was executed"
97
  assert len(episode.steps) == 1, "the episode ended there"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
  """A refusal from the operator is a step, not a traceback."""
4
 
5
  from tiny_computer_agent import (
6
  Action, AgentLoop, AllOf, AllowAll, AllowOnlyLabels, AnyOf, Approval, DenyAll, Element,
7
+ FakeScreen, InteractiveGate, MockBrain, Policy, gate_from_spec, mutating,
8
  )
9
 
10
 
 
97
  assert episode.steps[0].error is not None and episode.steps[0].error.startswith("not approved")
98
  assert episode.steps[0].result is None, "nothing was executed"
99
  assert len(episode.steps) == 1, "the episode ended there"
100
+
101
+
102
+ # -- the one-line spelling the command line takes -------------------------------- #
103
+
104
+ def test_gate_from_spec_read_only_allows_a_read_and_refuses_a_change():
105
+ gate = gate_from_spec("read-only")
106
+ assert gate.approve(Action("wait", seconds=1)).approved
107
+ refusal = gate.approve(Action("click", label="Save"))
108
+ assert refusal.approved is False
109
+ assert "read-only gate" in refusal.reason
110
+
111
+
112
+ def test_gate_from_spec_labels_normalise_what_the_operator_wrote():
113
+ gate = gate_from_spec("labels: save , Cancel")
114
+ assert gate.approve(Action("click", label="Save")).approved
115
+ assert gate.approve(Action("key", keys="ctrl+s")).approved is False
116
+
117
+
118
+ def test_gate_from_spec_interactive_asks_the_operator():
119
+ assert type(gate_from_spec("interactive")).__name__ == "InteractiveGate"
120
+
121
+
122
+ def test_gate_from_spec_allow_all_approves_whatever_policy_allowed():
123
+ assert gate_from_spec("allow-all").approve(Action("click", label="Save")).approved
124
+
125
+
126
+ def test_an_unknown_spec_says_what_it_accepts():
127
+ """A typo in a safety switch must never quietly mean "allow everything"."""
128
+ with pytest.raises(ValueError, match="allow-all"):
129
+ gate_from_spec("yes-please")
130
+
131
+
132
+ def test_labels_with_no_labels_is_a_configuration_error():
133
+ with pytest.raises(ValueError, match="at least one label"):
134
+ gate_from_spec("labels: , ")
tests/test_watchdog.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A call that never returns must not be a harness that never returns.
2
+
3
+ The loop stops a *repeated* action and a step budget stops a long episode, but nothing
4
+ stopped a single call that hangs: no step, no reason, no evidence, and a console that looks
5
+ like it is thinking. These tests pin the watchdog's two promises - the loop stops waiting and
6
+ says which call it stopped waiting for - and its one honest limit, that a Python thread
7
+ cannot be killed, only abandoned.
8
+ """
9
+
10
+ import time
11
+
12
+ import pytest
13
+
14
+ from tiny_computer_agent import (
15
+ AgentLoop, DryRunExecutor, Element, FakeScreen, MockBrain, Watchdog, WatchdogTimeout,
16
+ )
17
+
18
+
19
+ # -- the unit: one guarded call ------------------------------------------------ #
20
+
21
+ def test_a_call_that_returns_passes_its_value_through():
22
+ watchdog = Watchdog(step_timeout=5)
23
+ assert watchdog.call("decide", lambda a, b: a + b, 2, 3) == 5
24
+ assert watchdog.orphans == []
25
+
26
+
27
+ def test_a_call_that_overruns_is_abandoned_and_named():
28
+ watchdog = Watchdog(step_timeout=0.05)
29
+ started = time.monotonic()
30
+ with pytest.raises(WatchdogTimeout) as raised:
31
+ watchdog.call("execute", lambda: time.sleep(3))
32
+ elapsed = time.monotonic() - started
33
+
34
+ assert elapsed < 1.5, "the watchdog waited for the deadline, not for the call"
35
+ assert raised.value.where == "execute"
36
+ assert raised.value.seconds == 0.05
37
+ assert "execute did not finish within 0.05s" in str(raised.value)
38
+ assert watchdog.orphans == ["execute"], "an abandoned call is recorded, not forgiven"
39
+
40
+
41
+ def test_the_wall_clock_refuses_the_next_call_before_it_starts():
42
+ """A spent wall clock is reported as un-attempted, never as a timeout of what was next."""
43
+ watchdog = Watchdog(step_timeout=5, wall_clock=0).start()
44
+ called = []
45
+ with pytest.raises(WatchdogTimeout) as raised:
46
+ watchdog.call("decide", lambda: called.append("ran"))
47
+ assert called == [], "nothing may be attempted once the wall clock is spent"
48
+ assert raised.value.abandoned is False
49
+ assert "not attempted" in str(raised.value)
50
+
51
+
52
+ def test_no_per_call_deadline_runs_the_call_inline():
53
+ """The honest default for a local desktop: this process *is* the thing being watched."""
54
+ watchdog = Watchdog(step_timeout=None)
55
+ assert watchdog.call("execute", lambda: "inline") == "inline"
56
+ assert watchdog.threads_started == 0
57
+
58
+
59
+ def test_an_exception_inside_a_guarded_call_reaches_the_caller_unchanged():
60
+ def broken():
61
+ raise ValueError("the executor is broken, not slow")
62
+
63
+ with pytest.raises(ValueError, match="not slow"):
64
+ Watchdog(step_timeout=5).call("execute", broken)
65
+
66
+
67
+ def test_render_names_the_limits_and_every_abandoned_call():
68
+ watchdog = Watchdog(step_timeout=0.05, wall_clock=60).start()
69
+ with pytest.raises(WatchdogTimeout):
70
+ watchdog.call("decide", lambda: time.sleep(3))
71
+ line = watchdog.render()
72
+ assert "per call <= 0.05s" in line
73
+ assert "wall clock <= 60s" in line
74
+ assert "abandoned: decide" in line
75
+
76
+
77
+ def test_the_clock_starts_once_and_is_not_restarted_by_a_re_run():
78
+ watchdog = Watchdog(wall_clock=60).start()
79
+ time.sleep(0.02)
80
+ first = watchdog.elapsed
81
+ watchdog.start() # re-running a goal must not hand it a fresh budget
82
+ assert watchdog.elapsed >= first
83
+
84
+
85
+ def test_remaining_counts_down_and_then_reports_expired():
86
+ watchdog = Watchdog(wall_clock=0.05).start()
87
+ assert watchdog.remaining is not None and watchdog.remaining <= 0.05
88
+ time.sleep(0.06)
89
+ assert watchdog.expired is True
90
+ assert Watchdog(wall_clock=None).remaining is None
91
+
92
+
93
+ # -- the loop: a hung stage becomes a step, not a stall ------------------------- #
94
+
95
+ class SlowExecutor(DryRunExecutor):
96
+ seconds = 2.0
97
+
98
+ def execute(self, action, observation):
99
+ time.sleep(self.seconds)
100
+ return super().execute(action, observation)
101
+
102
+
103
+ class SlowScreen(FakeScreen):
104
+ def capture(self):
105
+ time.sleep(2.0)
106
+ return super().capture()
107
+
108
+
109
+ class SlowBrain(MockBrain):
110
+ def decide(self, goal, observation):
111
+ time.sleep(2.0)
112
+ return super().decide(goal, observation)
113
+
114
+
115
+ def _screen():
116
+ return FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
117
+
118
+
119
+ def test_a_hung_executor_ends_the_episode_with_a_named_reason():
120
+ loop = AgentLoop(screen=_screen(), brain=MockBrain([{"kind": "click", "label": "Save"}]),
121
+ executor=SlowExecutor(), watchdog=Watchdog(step_timeout=0.05))
122
+ started = time.monotonic()
123
+ episode = loop.run("click 'Save' to save the document")
124
+ assert time.monotonic() - started < 2.0, "the loop must not wait for the hung call"
125
+
126
+ assert episode.success is False
127
+ assert episode.stop_reason.startswith("watchdog stopped the episode")
128
+ assert "execute did not finish within 0.05s" in episode.steps[-1].error
129
+ assert loop.watchdog.orphans == ["execute"]
130
+
131
+
132
+ def test_a_hung_brain_ends_the_episode_too():
133
+ loop = AgentLoop(screen=_screen(), brain=SlowBrain([{"kind": "done", "reason": "x"}]),
134
+ watchdog=Watchdog(step_timeout=0.05))
135
+ episode = loop.run("save")
136
+ assert episode.success is False
137
+ assert "decide did not finish within 0.05s" in episode.steps[-1].error
138
+ assert loop.watchdog.orphans == ["decide"]
139
+
140
+
141
+ def test_a_hung_capture_is_recorded_before_the_episode_starts():
142
+ loop = AgentLoop(screen=SlowScreen([(Element("Save", x=1, y=1, width=10, height=10),)]),
143
+ brain=MockBrain([{"kind": "done", "reason": "x"}]),
144
+ watchdog=Watchdog(step_timeout=0.05))
145
+ episode = loop.run("save")
146
+ assert episode.success is False
147
+ assert "watchdog stopped the episode" in episode.stop_reason
148
+ assert "capture did not finish within 0.05s" in episode.stop_reason
149
+
150
+
151
+ def test_without_a_watchdog_a_slow_but_finite_call_still_completes():
152
+ """Guarding is opt-in: a local desktop should not pay for a thread per step."""
153
+ loop = AgentLoop(screen=_screen(), brain=MockBrain([{"kind": "done", "reason": "fine"}]),
154
+ executor=SlowExecutor())
155
+ loop.executor.seconds = 0.05
156
+ episode = loop.run("save")
157
+ assert episode.success is True
158
+ assert loop.watchdog is None