@capagents/forgeqe 1.2.0 → 1.3.0

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 (75) hide show
  1. package/HOW_TO_USE.md +54 -3
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +66 -8
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +36 -4
  9. package/prompts/browser-api.md +13 -0
  10. package/prompts/browser-capture.md +2 -2
  11. package/prompts/browser-checks.md +2 -0
  12. package/py/forgeqe_engine/api_step.py +130 -0
  13. package/py/forgeqe_engine/capture.py +53 -2
  14. package/py/forgeqe_engine/checks.py +8 -2
  15. package/py/forgeqe_engine/llm.py +33 -5
  16. package/py/forgeqe_engine/memory.py +70 -2
  17. package/py/forgeqe_engine/observe.py +123 -0
  18. package/py/forgeqe_engine/replay.py +55 -2
  19. package/py/forgeqe_engine/service.py +177 -5
  20. package/src/api/fromSteps.ts +111 -0
  21. package/src/capabilities/registry.ts +19 -5
  22. package/src/cases/ado.ts +1 -1
  23. package/src/cases/journey.ts +61 -0
  24. package/src/cases/quality.ts +3 -1
  25. package/src/cases/readiness.ts +35 -6
  26. package/src/cases/save.ts +241 -48
  27. package/src/cases/types.ts +11 -0
  28. package/src/cli/help.ts +8 -0
  29. package/src/cli/index.ts +64 -1
  30. package/src/code/coverage.ts +22 -3
  31. package/src/code/decisions.ts +73 -0
  32. package/src/code/find.ts +3 -1
  33. package/src/code/graph/graph.ts +33 -2
  34. package/src/code/handoff.ts +20 -0
  35. package/src/code/loop.ts +27 -1
  36. package/src/code/notes.ts +48 -1
  37. package/src/code/prompt.ts +31 -9
  38. package/src/code/protect.ts +103 -0
  39. package/src/code/replay.ts +33 -0
  40. package/src/code/review.ts +2 -0
  41. package/src/code/runtime.ts +35 -3
  42. package/src/code/shortlist.ts +309 -0
  43. package/src/code/testrun.ts +3 -1
  44. package/src/code/traceability.ts +2 -0
  45. package/src/commands.ts +6 -1
  46. package/src/config.ts +46 -0
  47. package/src/engine/bridge.ts +47 -5
  48. package/src/mcp/casetools.ts +107 -11
  49. package/src/mcp/commands.ts +74 -20
  50. package/src/mcp/packtools.ts +187 -0
  51. package/src/mcp/tools.ts +238 -29
  52. package/src/pack/collect.ts +251 -0
  53. package/src/pack/index.ts +134 -0
  54. package/src/pack/init.ts +45 -0
  55. package/src/pack/pack.ts +327 -0
  56. package/src/pack/pipeline.ts +373 -0
  57. package/src/pack/run.ts +155 -0
  58. package/src/registry/db.ts +249 -0
  59. package/src/registry/index.ts +72 -0
  60. package/src/registry/resolve.ts +57 -0
  61. package/src/registry/tier0.ts +81 -0
  62. package/src/report/data.ts +7 -0
  63. package/src/report/html.ts +14 -0
  64. package/src/report/template.html +52 -48
  65. package/src/report/usage.ts +113 -0
  66. package/src/run.ts +32 -2
  67. package/src/suite/report.ts +22 -4
  68. package/src/suite/run.ts +13 -5
  69. package/src/summary.ts +6 -0
  70. package/src/tokenomics/budget.ts +172 -0
  71. package/src/tokenomics/ledger.ts +287 -0
  72. package/src/tui/App.tsx +90 -2
  73. package/src/tui/inspector.tsx +40 -35
  74. package/src/tui/transcript.tsx +5 -4
  75. package/src/types.ts +11 -2
@@ -6,7 +6,7 @@ import logging
6
6
  import os
7
7
  import re
8
8
  import time
9
- from typing import Any, Dict, List, Optional, Tuple
9
+ from typing import Any, Callable, Dict, List, Optional, Tuple
10
10
 
11
11
  logger = logging.getLogger("forgeqe_engine.service")
12
12
 
@@ -157,18 +157,46 @@ def _build_llm(llm_config: Optional[Dict[str, Any]]):
157
157
  raise ValueError(f"Unsupported LLM provider for the engine: {provider}")
158
158
 
159
159
 
160
- def _time_llm_calls(llm: Any, calls: List[Tuple[float, float]]) -> None:
161
- """Record when each model call started and ended, so the report can split step time into model and browser."""
160
+ def call_usage(result: Any) -> Dict[str, int]:
161
+ """One model call's tokens from its completion (browser-use ChatInvokeUsage); prompt tokens include cached ones."""
162
+ u = getattr(result, "usage", None)
163
+ if u is None:
164
+ return {}
165
+ out = {"input": int(getattr(u, "prompt_tokens", 0) or 0), "output": int(getattr(u, "completion_tokens", 0) or 0)}
166
+ cached = int(getattr(u, "prompt_cached_tokens", 0) or 0)
167
+ written = int(getattr(u, "prompt_cache_creation_tokens", 0) or 0)
168
+ if cached:
169
+ out["cached"] = cached
170
+ if written:
171
+ out["cache_write"] = written
172
+ return out
173
+
174
+
175
+ def _time_llm_calls(llm: Any, calls: List[Tuple[float, float]], on_call: Optional[Callable[[Dict[str, Any]], None]] = None) -> None:
176
+ """
177
+ Record when each model call started and ended, so the report can split step time into model and browser;
178
+ on_call also gets each call's time and tokens (counts only, never the prompt), for ForgeQE's usage ledger.
179
+ """
162
180
  inner = getattr(llm, "ainvoke", None)
163
181
  if inner is None:
164
182
  return
165
183
 
166
184
  async def timed(*args: Any, **kwargs: Any) -> Any:
167
185
  started = time.time()
186
+ result: Any = None
187
+ ok = False
168
188
  try:
169
- return await inner(*args, **kwargs)
189
+ result = await inner(*args, **kwargs)
190
+ ok = True
191
+ return result
170
192
  finally:
171
- calls.append((started, time.time()))
193
+ ended = time.time()
194
+ calls.append((started, ended))
195
+ if on_call is not None:
196
+ try:
197
+ on_call({"ms": int((ended - started) * 1000), "ok": ok, **call_usage(result)})
198
+ except Exception:
199
+ logger.debug("model call usage not reported", exc_info=True)
172
200
 
173
201
  try:
174
202
  object.__setattr__(llm, "ainvoke", timed)
@@ -5,6 +5,7 @@ Layout under the memory directory:
5
5
 
6
6
  pages/<host>/<page>.json every interactive element seen on that page, each with ranked locators
7
7
  flows/<goal>-<hash>.json the actions of the last successful run of a goal, replayable without the LLM
8
+ cases/<hash>.json for a test case: which flow it last passed with, so an edited case replays its unchanged steps
8
9
 
9
10
  A locator's confidence (1-99 %) multiplies four factors: how stable its kind is (test id > id >
10
11
  role+name > ... > xpath), how unique it was on the page, how consistently it reappeared on later
@@ -650,22 +651,44 @@ class MemoryStore:
650
651
  replayed: int = 0,
651
652
  llm_steps: int = 0,
652
653
  resume: Optional[Dict[str, Any]] = None,
654
+ case: Optional[Dict[str, Any]] = None,
655
+ checks: Optional[List[Dict[str, Any]]] = None,
653
656
  ) -> Optional[str]:
654
657
  """
655
658
  Update the goal's flow after a run. Only successful runs replace its steps. Returns "saved" or "updated".
656
659
 
657
660
  A failed run with `resume` (the steps that worked before it went wrong) keeps them under "resume", so the
658
661
  next run of this task replays them and the agent picks up where the failed run went wrong.
662
+
663
+ `case` ({id, keys, ends}) is the test case the goal came from: a key per case step, and for each the number
664
+ of flow steps done by the end of it (None where unknown). A successful run records it and points the case's
665
+ index at this flow, so a run of the case after an edit replays the steps before the first one that changed.
659
666
  """
660
667
  if status == "stopped":
661
668
  return None
662
669
  with _locked(self.root):
663
670
  return self._save_flow_result(goal, start_url, status=status, steps=steps, summary=summary,
664
- final_url=final_url, replayed=replayed, llm_steps=llm_steps, resume=resume)
671
+ final_url=final_url, replayed=replayed, llm_steps=llm_steps, resume=resume, case=case,
672
+ checks=checks)
673
+
674
+ def case_file(self, case_id: str) -> Path:
675
+ return self.root / "cases" / f"{_sha(case_id, 16)}.json"
676
+
677
+ def load_case_flow(self, case_id: str) -> Optional[Dict[str, Any]]:
678
+ """The flow a test case last passed with, when it recorded where each case step ended."""
679
+ index = _read_json(self.case_file(case_id)) if case_id else None
680
+ name = str((index or {}).get("flow") or "")
681
+ if not name or "/" in name or "\\" in name:
682
+ return None
683
+ flow = _read_json(self.root / "flows" / name)
684
+ if not flow or flow.get("case_id") != case_id or not flow.get("successes") or not isinstance(flow.get("steps"), list):
685
+ return None
686
+ return flow
665
687
 
666
688
  def _save_flow_result(self, goal: str, start_url: Optional[str], *, status: str, steps: List[Dict[str, Any]],
667
689
  summary: str, final_url: str, replayed: int, llm_steps: int,
668
- resume: Optional[Dict[str, Any]] = None) -> Optional[str]:
690
+ resume: Optional[Dict[str, Any]] = None, case: Optional[Dict[str, Any]] = None,
691
+ checks: Optional[List[Dict[str, Any]]] = None) -> Optional[str]:
669
692
  file = self.flow_file(goal, start_url)
670
693
  flow = self.load_flow(goal, start_url)
671
694
  created = flow is None
@@ -689,6 +712,18 @@ class MemoryStore:
689
712
  flow["llm_steps"] = llm_steps
690
713
  flow["replayed_steps"] = replayed
691
714
  flow.pop("resume", None)
715
+ # checks: what the run checked, with the flow step each came after (observe.py); None keeps the flow's.
716
+ # New steps from the agent with no checks given leave none, since old positions would not fit them.
717
+ if checks is not None:
718
+ flow["checks"] = checks
719
+ elif llm_steps and flow.get("checks"):
720
+ flow.pop("checks", None)
721
+ if case and case.get("id") and case.get("keys"):
722
+ kept = len(flow["steps"])
723
+ flow["case_id"] = str(case["id"])
724
+ flow["case_step_keys"] = [str(k) for k in case["keys"]]
725
+ flow["case_step_ends"] = [e if isinstance(e, int) and 0 < e <= kept else None for e in (case.get("ends") or [])][: len(case["keys"])]
726
+ _write_json(self.case_file(str(case["id"])), {"case_id": str(case["id"]), "flow": file.name, "updated": now})
692
727
  elif status != "complete":
693
728
  flow["failures"] = int(flow.get("failures") or 0) + 1
694
729
  if keep_resume and resume:
@@ -702,6 +737,28 @@ class MemoryStore:
702
737
  return None
703
738
  return "saved" if created else "updated"
704
739
 
740
+ @staticmethod
741
+ def case_prefix(flow: Dict[str, Any], keys: List[str]) -> Tuple[int, int]:
742
+ """
743
+ How much of a case's earlier flow still fits its steps now: (case steps, flow steps). The case steps are the
744
+ leading ones whose keys did not change, up to the last of them where the flow recorded its end. (0, 0) when none.
745
+
746
+ An end is trusted only when the next step that recorded one ended later: when the agent did the actions of two
747
+ steps before recording either check, both ends are the same, and replaying up to the first would also redo
748
+ the second step's actions, which may be the step that changed.
749
+ """
750
+ old = [str(k) for k in (flow.get("case_step_keys") or [])]
751
+ ends = [e if isinstance(e, int) and 0 < e <= len(flow.get("steps") or []) else None for e in (flow.get("case_step_ends") or [])]
752
+ same = 0
753
+ while same < min(len(old), len(keys)) and old[same] == keys[same]:
754
+ same += 1
755
+ for n in range(same, 0, -1):
756
+ end = ends[n - 1] if n - 1 < len(ends) else None
757
+ later = next((e for e in ends[n:] if e is not None), None)
758
+ if end is not None and (later is None or later > end):
759
+ return n, end
760
+ return 0, 0
761
+
705
762
  def recent_flows(self, host: str, *, exclude: Optional[Path] = None, limit: int = 2) -> List[Dict[str, Any]]:
706
763
  folder = self.root / "flows"
707
764
  if not folder.is_dir():
@@ -1001,4 +1058,15 @@ def _compact_step(step: Dict[str, Any]) -> Dict[str, Any]:
1001
1058
  return {"op": "back"}
1002
1059
  if action == "evaluate":
1003
1060
  return {"op": "evaluate", "code": str(p.get("code") or "")[:2000]}
1061
+ if action == "api_request":
1062
+ # An HTTP call made in the page's session: what was sent, and what the server answered when the run made it.
1063
+ out = {"op": "api", "method": str(p.get("method") or "GET").upper(), "url": p.get("url") or ""}
1064
+ for k in ("body", "content_type", "csrf"):
1065
+ if p.get(k):
1066
+ out[k] = p[k]
1067
+ answer = step.get("api") or {}
1068
+ for k in ("status", "final_url", "redirected", "snippet"):
1069
+ if answer.get(k) not in (None, "", False):
1070
+ out[k] = answer[k]
1071
+ return out
1004
1072
  return {"op": "unknown", "action_keys": [action]}
@@ -0,0 +1,123 @@
1
+ """
2
+ Replaying a flow without the model, and knowing it still passes (docs/COST_ARCHITECTURE.md, phase 2).
3
+
4
+ Two parts:
5
+ - read_only_script: whether an `evaluate` step in a flow only read the page. The agent often runs a script to look at
6
+ the page (list the fields, read their values) before it decides; replay can leave such a step out. The test is on
7
+ the script's source and conservative: anything that could write (assign, call a method that changes the page or the
8
+ browser's state, send a request, schedule code) counts as writing, and the step then needs the agent as before.
9
+ - checks_for_flow / flow_checks_hold: the results a passing run recorded (record_check), kept in its flow with the
10
+ step each was recorded after, and checked again on the replayed page with no model.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import re
16
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
17
+
18
+ # Anything that writes to the page, the browser, or the network, or runs code later. A script with any of these
19
+ # is not read-only. False positives only cost a model call (today's path); a false negative could skip a real step,
20
+ # so the list leans wide.
21
+ _WRITES = re.compile(
22
+ r"""
23
+ (?<![=!<>])=(?![=>]) # any assignment (=, +=, -=, ...), not ==, ===, =>, <=, >=, !=
24
+ | \+\+ | -- # increments
25
+ | \b(?:delete)\b
26
+ | \.(?:click|submit|requestSubmit|reset|focus|blur|select|setSelectionRange|setRangeText|showModal|show|close|open|
27
+ dispatchEvent|remove|removeChild|append|appendChild|prepend|insertBefore|insertAdjacent\w*|replaceChild|
28
+ replaceWith|replaceChildren|before|after|setAttribute\w*|removeAttribute\w*|toggleAttribute|
29
+ scroll\w*|play|pause|load|reload|assign|pushState|replaceState|back|forward|go|
30
+ setItem|removeItem|clear|add|toggle|set|setProperty|removeProperty|attachShadow|
31
+ sendBeacon|postMessage|writeText|write|writeln|execCommand|requestFullscreen|exitFullscreen)\s*\(
32
+ | \b(?:fetch|XMLHttpRequest|WebSocket|EventSource|eval|Function|setTimeout|setInterval|requestAnimationFrame|
33
+ queueMicrotask|importScripts|import)\s*\(
34
+ | \bnew\s+(?:\w*Event|XMLHttpRequest|WebSocket|Worker|SharedWorker|Function|MutationObserver|BroadcastChannel)\b
35
+ | \b(?:document\.cookie|location|indexedDB|caches|navigator\.serviceWorker)\b
36
+ """,
37
+ re.VERBOSE,
38
+ )
39
+
40
+ # Declarations (const x = ..., let x = ..., var x = ..., function parameters with defaults) write only local names.
41
+ _DECLARATION = re.compile(r"\b(?:const|let|var)\s+(?:[A-Za-z_$][\w$]*|\{[^}]*\}|\[[^\]]*\])\s*=(?![=>])")
42
+ # Text inside string and template literals: an "=" or ".click(" in a selector or a message is not code.
43
+ _STRINGS = re.compile(r"""'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`""", re.DOTALL)
44
+ # Comments.
45
+ _COMMENTS = re.compile(r"//[^\n]*|/\*.*?\*/", re.DOTALL)
46
+
47
+
48
+ def read_only_script(code: str) -> bool:
49
+ """Whether a script only reads the page: no assignment to anything but its own new local names, no call that changes
50
+ the page, the browser, or storage, no request, and no code run later."""
51
+ src = str(code or "")
52
+ if not src.strip() or len(src) > 20_000:
53
+ return False
54
+ src = _COMMENTS.sub(" ", src)
55
+ src = _STRINGS.sub('""', src)
56
+ # `const x = ...`: the assignment is the declaration of a local name. Remove the "=" of each declaration only;
57
+ # the expression after it is still checked.
58
+ src = _DECLARATION.sub(lambda m: m.group(0)[:-1] + " ", src)
59
+ # Parameters with defaults, `(a = 1) =>`: rare in these scripts; treated as an assignment, so not read-only.
60
+ return _WRITES.search(src) is None
61
+
62
+
63
+ def read_only_step(step: Dict[str, Any]) -> bool:
64
+ """A flow step replay can leave out: an `evaluate` whose script only reads the page."""
65
+ return step.get("action") == "evaluate" and read_only_script(str((step.get("params") or {}).get("code") or ""))
66
+
67
+
68
+ # ---------------------------------------------------------------------------------------------
69
+ # Recorded checks
70
+ # ---------------------------------------------------------------------------------------------
71
+
72
+ CHECK_ATTEMPTS = 6
73
+ CHECK_DELAY_SECONDS = 0.5
74
+
75
+
76
+ def checks_for_flow(checks: List[Dict[str, Any]], at: Dict[str, int]) -> List[Dict[str, Any]]:
77
+ """The checks a passing run recorded, as its flow keeps them: what to find on the page, and after which flow step."""
78
+ out: List[Dict[str, Any]] = []
79
+ for c in checks or []:
80
+ text = " ".join(str(c.get("text") or "").split())
81
+ if not text:
82
+ continue
83
+ entry: Dict[str, Any] = {"expected": str(c.get("expected") or text)[:200], "text": text[:200], "at": int(at.get(check_key(c), 0))}
84
+ if c.get("url"):
85
+ entry["url"] = str(c["url"])
86
+ if c.get("dynamic"):
87
+ entry["dynamic"] = True
88
+ if c.get("holds") is False:
89
+ entry["holds"] = False
90
+ if isinstance(c.get("step"), int) and c["step"] > 0:
91
+ entry["step"] = c["step"]
92
+ out.append(entry)
93
+ return out
94
+
95
+
96
+ def check_key(c: Dict[str, Any]) -> str:
97
+ return str(c.get("expected") or c.get("text") or "").strip().lower()
98
+
99
+
100
+ def checkable(flow: Dict[str, Any]) -> Optional[str]:
101
+ """None when the flow's checks can decide a replay on their own; otherwise why not, in words."""
102
+ checks = flow.get("checks")
103
+ if not isinstance(checks, list) or not checks:
104
+ return "the flow has no recorded checks"
105
+ if any(c.get("dynamic") for c in checks):
106
+ return "a recorded check is live data (dynamic), which only the agent can judge"
107
+ if any(c.get("holds") is False for c in checks):
108
+ return "a recorded check did not hold when it was recorded"
109
+ return None
110
+
111
+
112
+ async def check_holds(shows: Callable[[str], Awaitable[Optional[bool]]], text: str, attempts: Optional[int] = None, delay: Optional[float] = None) -> Optional[bool]:
113
+ """Whether the page shows `text`, asking again for a few seconds while it settles; None when it could not be asked."""
114
+ attempts = CHECK_ATTEMPTS if attempts is None else attempts
115
+ delay = CHECK_DELAY_SECONDS if delay is None else delay
116
+ seen: Optional[bool] = None
117
+ for n in range(max(1, attempts)):
118
+ seen = await shows(text)
119
+ if seen:
120
+ return True
121
+ if n + 1 < attempts:
122
+ await asyncio.sleep(delay)
123
+ return seen
@@ -16,6 +16,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
16
16
 
17
17
  from . import brand
18
18
  from .logins import totp_code
19
+ from .observe import check_holds, read_only_step
19
20
  from .memory import (
20
21
  ELEMENT_ACTIONS,
21
22
  READ_ONLY_ACTIONS,
@@ -45,6 +46,10 @@ class ReplayOutcome:
45
46
  times: List[Tuple[float, float]] = field(default_factory=list)
46
47
  # Steps whose recorded locator no longer matched and another one found the element.
47
48
  heals: List[Dict[str, Any]] = field(default_factory=list)
49
+ # The flow's recorded checks found on the page again, each after the step it was recorded after (observe.py).
50
+ checked: List[Dict[str, Any]] = field(default_factory=list)
51
+ # Steps left out because they only read the page (an `evaluate` with a read-only script).
52
+ skipped_reads: int = 0
48
53
 
49
54
  @property
50
55
  def complete(self) -> bool:
@@ -86,7 +91,13 @@ async def replay_flow(
86
91
  cancel_event: asyncio.Event,
87
92
  on_step: Optional[Callable[[], Awaitable[None]]] = None,
88
93
  overlay: Any = None,
94
+ checks_first: bool = False,
89
95
  ) -> ReplayOutcome:
96
+ """
97
+ `checks_first` (registry.mode on, docs/COST_ARCHITECTURE.md): steps that only read the page are left out, and the
98
+ flow's recorded checks are found on the page again after the step each was recorded after. A check that does not
99
+ hold stops the replay there, so the agent takes over on that page as after any other stop.
100
+ """
90
101
  from browser_use import Tools
91
102
 
92
103
  tools = Tools()
@@ -99,6 +110,23 @@ async def replay_flow(
99
110
  out.reason = reason
100
111
  return out
101
112
 
113
+ checks = [c for c in (flow.get("checks") or []) if isinstance(c, dict) and c.get("text")] if checks_first else []
114
+
115
+ async def verify_checks(after: int) -> Optional[str]:
116
+ """Finds again the checks recorded after flow step `after`; the first that does not hold, in words."""
117
+ due = [c for c in checks if (int(c.get("at") or 0) == after) or (after == len(steps) and not 0 < int(c.get("at") or 0) <= len(steps))]
118
+ if not due:
119
+ return None
120
+ from .checks import page_shows
121
+
122
+ for c in due:
123
+ holds = await check_holds(lambda t: page_shows(browser, t), str(c["text"]))
124
+ out.checked.append({**c, "holds_now": bool(holds)})
125
+ emit({"type": "result", "step": after, "items": [{"ok": bool(holds), "done": False, "text": f'check "{c.get("expected") or c["text"]}": {"shows" if holds else "does not show"} "{c["text"]}"'[:240]}]})
126
+ if not holds:
127
+ return f'the recorded check "{c.get("expected") or c["text"]}" does not hold: the page does not show "{c["text"]}"'
128
+ return None
129
+
102
130
  for n, step in enumerate(steps, 1):
103
131
  if cancel_event.is_set():
104
132
  stop(n, "stopped by user")
@@ -106,6 +134,17 @@ async def replay_flow(
106
134
  action = str(step.get("action") or "")
107
135
  params = dict(step.get("params") or {})
108
136
  line = str(step.get("line") or action)
137
+ if checks_first and read_only_step(step):
138
+ # The agent looked at the page here; replay has nothing to do. Kept in the flow so check positions hold.
139
+ out.done.append(step)
140
+ out.times.append((time.time(), time.time()))
141
+ out.emitted = n
142
+ out.skipped_reads += 1
143
+ failed = await verify_checks(n)
144
+ if failed:
145
+ stop(n + 1 if n < len(steps) else n, failed)
146
+ break
147
+ continue
109
148
  if action not in REPLAYABLE_ACTIONS:
110
149
  stop(n, f"`{line}` needs the agent")
111
150
  break
@@ -188,6 +227,10 @@ async def replay_flow(
188
227
  out.heals.append({"step": n, "line": line, "label": step.get("label") or "", "stale": [_locator_text(k) for k in gone], "used": _locator_text(used)})
189
228
  if on_step is not None:
190
229
  await on_step()
230
+ failed = await verify_checks(n)
231
+ if failed:
232
+ stop(n + 1 if n < len(steps) else n, failed)
233
+ break
191
234
 
192
235
  out.url = await _current_url(browser)
193
236
  return out
@@ -197,11 +240,14 @@ def handoff_task(goal: str, flow: Dict[str, Any], outcome: ReplayOutcome, *, kin
197
240
  """
198
241
  The agent's task after a replay: the goal, what already happened, and where to pick up.
199
242
 
200
- `kind` is "exact" (an earlier successful run of this task), "resume" (the steps that worked in a run of this
243
+ `kind` is "exact" (an earlier successful run of this task), "prefix" (the recorded steps of a test case's unchanged
244
+ first steps, after the case was edited), "resume" (the steps that worked in a run of this
201
245
  task that later went wrong), or "interrupted" (the steps a run of this task got through before it was cut off: closed, crashed, or the machine restarted).
202
246
  """
203
247
  when = str(flow.get("last_success") or flow.get("at") or "")[:10]
204
- if kind == "resume":
248
+ if kind == "prefix":
249
+ source = "an earlier successful run of this test case, before its later steps were changed"
250
+ elif kind == "resume":
205
251
  source = "an earlier run of this task that went wrong later"
206
252
  elif kind == "interrupted":
207
253
  source = "an earlier run of this task that was cut off before it finished"
@@ -221,6 +267,13 @@ def handoff_task(goal: str, flow: Dict[str, Any], outcome: ReplayOutcome, *, kin
221
267
  f"All recorded steps ran and the browser is on {outcome.url or 'the final page'}. Check this page: "
222
268
  "if the task is complete, call done right away and report the result. Otherwise continue from here."
223
269
  )
270
+ elif kind == "prefix":
271
+ n = int(flow.get("case_steps_done") or 0)
272
+ lines.append(
273
+ f"They did test steps 1 to {n}, which have not changed. The browser is on {outcome.url or 'the current page'}. "
274
+ f"Check that the page agrees, then continue with test step {n + 1}. In your step report, report steps 1 to {n} "
275
+ "from what the page shows now."
276
+ )
224
277
  elif kind == "interrupted":
225
278
  lines.append(
226
279
  f"That is as far as the earlier run got before it was cut off. The browser is on {outcome.url or 'the current page'}. "