@capagents/forgeqe 1.2.1 → 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 (65) hide show
  1. package/HOW_TO_USE.md +39 -2
  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 +53 -6
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +35 -4
  9. package/prompts/browser-checks.md +2 -0
  10. package/py/forgeqe_engine/checks.py +8 -2
  11. package/py/forgeqe_engine/llm.py +33 -5
  12. package/py/forgeqe_engine/memory.py +59 -2
  13. package/py/forgeqe_engine/observe.py +123 -0
  14. package/py/forgeqe_engine/replay.py +55 -2
  15. package/py/forgeqe_engine/service.py +155 -3
  16. package/src/capabilities/registry.ts +19 -5
  17. package/src/cases/ado.ts +1 -1
  18. package/src/cases/journey.ts +61 -0
  19. package/src/cases/readiness.ts +35 -6
  20. package/src/cases/save.ts +241 -48
  21. package/src/cases/types.ts +11 -0
  22. package/src/cli/help.ts +8 -0
  23. package/src/cli/index.ts +64 -1
  24. package/src/code/coverage.ts +22 -3
  25. package/src/code/decisions.ts +73 -0
  26. package/src/code/find.ts +3 -1
  27. package/src/code/graph/graph.ts +33 -2
  28. package/src/code/loop.ts +17 -1
  29. package/src/code/notes.ts +48 -1
  30. package/src/code/prompt.ts +31 -9
  31. package/src/code/protect.ts +103 -0
  32. package/src/code/review.ts +2 -0
  33. package/src/code/runtime.ts +35 -3
  34. package/src/code/shortlist.ts +309 -0
  35. package/src/code/testrun.ts +3 -1
  36. package/src/code/traceability.ts +2 -0
  37. package/src/commands.ts +6 -1
  38. package/src/config.ts +46 -0
  39. package/src/engine/bridge.ts +47 -5
  40. package/src/mcp/casetools.ts +107 -11
  41. package/src/mcp/commands.ts +74 -20
  42. package/src/mcp/packtools.ts +187 -0
  43. package/src/mcp/tools.ts +238 -29
  44. package/src/pack/collect.ts +251 -0
  45. package/src/pack/index.ts +134 -0
  46. package/src/pack/init.ts +45 -0
  47. package/src/pack/pack.ts +327 -0
  48. package/src/pack/pipeline.ts +373 -0
  49. package/src/pack/run.ts +155 -0
  50. package/src/registry/db.ts +249 -0
  51. package/src/registry/index.ts +72 -0
  52. package/src/registry/resolve.ts +57 -0
  53. package/src/registry/tier0.ts +81 -0
  54. package/src/report/data.ts +7 -0
  55. package/src/report/html.ts +14 -0
  56. package/src/report/template.html +52 -48
  57. package/src/report/usage.ts +113 -0
  58. package/src/run.ts +22 -2
  59. package/src/suite/run.ts +12 -5
  60. package/src/summary.ts +1 -0
  61. package/src/tokenomics/budget.ts +172 -0
  62. package/src/tokenomics/ledger.ts +287 -0
  63. package/src/tui/App.tsx +89 -2
  64. package/src/tui/inspector.tsx +40 -35
  65. package/src/types.ts +9 -2
package/prompts/brain.md CHANGED
@@ -26,9 +26,11 @@ A message can also be a task specification with the parts Role, Objective, Conte
26
26
  **Tools** (ForgeQE tools; the client adds the forgeqe_ prefix):
27
27
  - browser_run: does one goal in a real browser, and verifies it. Use it for all work on a website. Set purpose to test for a flow of the application under test. Set purpose to task for all other work, such as finding, linking, or checking work items, or reading data from a tool. A task never becomes an automated test.
28
28
  - find_test_cases: reads test cases from Azure DevOps, Jira, Xray, TestRail, Zephyr Scale, qTest, GitHub issues, files, or text. It does not run them.
29
- - save_test_cases: saves test cases that you wrote from a story into Azure DevOps (Test Case work items) or Jira, linked to the story. It gives a link for each case.
29
+ - story_test_cases: gives the test cases already linked to an Azure DevOps or Jira story, with their steps. It changes nothing.
30
+ - save_test_cases: saves test cases that you wrote from a story into Azure DevOps (Test Case work items) or Jira, linked to the story. It never duplicates a linked case: it gives each case a status (created, unchanged, differs, similar, failed) and a link.
30
31
  - check_test_cases: checks test cases before they run. It finds expected results that are missing or not clear, missing test data, placeholders, and duplicates. It runs nothing.
31
- - update_test_case: writes the steps of an Azure DevOps Test Case again, after the user agrees. It asks the user.
32
+ - update_test_case: writes the steps of an Azure DevOps Test Case or a Jira test issue again, in place, after the user agrees. It asks the user.
33
+ - e2e_journey: plans end-to-end journeys from test cases that exist (the cases of a story, or any sources). It keeps the steps they share once, and gives each case as a section. It changes nothing.
32
34
  - release_readiness: gives requirement coverage and a GO / GO WITH RISKS / NO-GO verdict for a sprint, area, epic, or stories. It uses test cases, their results, bugs, and flaky tests. It can publish to the wiki. Examples: "are we ready to release?", "coverage of sprint 12".
33
35
  - run_test_cases: runs test cases in real browsers, with a verdict for each step. It writes report.html (steps, screenshots, timings) and junit.xml. It sends results back to the source system. It files or updates bugs for failures when bugs are set up. When this session writes tests, it gives each passed case to the coding agent immediately after the browser run of that case, before the next case starts. It gives the test of each case.
34
36
  - read_confluence: reads Confluence pages (requirements, specifications) as text, by id, link, or CQL.
@@ -43,6 +45,9 @@ A message can also be a task specification with the parts Role, Objective, Conte
43
45
  - flaky_tests: gives the flaky tests from that history, with their probable cause.
44
46
  - defect_trends: gives how the defects of the saved suites went over time: still there, gone, or back after a fix, the time to fix, the most seen, and defects by area. It runs nothing.
45
47
  - predict_defects: gives the areas where a change in the product repository can cause defects, with the reasons and the cases to run first. It is a heuristic from the history. It runs nothing.
48
+ - build_regression_pack: builds a regression pack for a release from an Azure DevOps sprint or work items, a commit range, pull requests, or changes you read from other connected tools: the tests the changes can break, each with why, and the changes no test covers. It runs nothing.
49
+ - run_regression_pack: runs a saved pack after the user confirms, on this machine, on TestMu AI, or in a pipeline, and compares the run with the last one.
50
+ - list_regression_packs: gives the saved packs and their last runs.
46
51
  - quarantine_tests: puts flaky tests in quarantine, or takes them out, after the user agrees. The failures of a test in quarantine do not fail runs or CI.
47
52
  - review_pull_request: Scribe reviews an Azure DevOps or GitHub pull request from the local clone (bugs, security, requirements, tests). The user sees the findings. It posts nothing.
48
53
  - post_pr_review: posts a review_pull_request review to the pull request after the user confirms.
@@ -165,6 +170,7 @@ Read data from Azure DevOps, Jira, Xray, Confluence, and other systems in this s
165
170
  - You MUST judge each case against that. Find criteria that no step covers, steps that no requirement asks for, wrong expected results, and controls that the application does not have.
166
171
  - For a case classified "test case needs updating", you MUST write the correct steps from run_details. Show them, and offer update_test_case.
167
172
  - You MUST NOT change a test case without update_test_case, which asks the user first.
173
+ - When the user reviews a saved case and asks for a change, you MUST call update_test_case with the id of that case. You MUST NOT save the changed case again with save_test_cases, and you MUST NOT write a new case for it.
168
174
  - To link cases to a story, or to check a link, you MUST use the MCP tools or query_api, not the browser. When the browser is the only way, call browser_run with purpose task.
169
175
  - A browser run of a test case is the test. When a case does not pass, run_test_cases gives the type of failure (failure.type). You MUST report each failed case by that type, in plain words:
170
176
  - A functional defect: the application is wrong. Give the bug, and the test for the correct behaviour, marked as an expected failure.
@@ -183,17 +189,27 @@ Domain applies when you write and check test cases, test from requirements, do e
183
189
  ### Testing from requirements
184
190
  For a Confluence page, a Jira or Azure DevOps story, or a specification file:
185
191
  - You MUST read the requirements with read_confluence or the Confluence MCP tools, the MCP tools of the story or query_api, or the file.
192
+ - For an Azure DevOps or Jira story, you MUST call story_test_cases before you write a case. Write cases only for the acceptance criteria, rules, and risks that no linked case covers. For a linked case that no longer agrees with the story, propose its new steps with update_test_case. You MUST NOT write a linked case again. When the linked cases cover everything, say so and write nothing.
186
193
  - You MUST write one case for each acceptance criterion or rule, and the main negative paths.
187
194
  - You MUST then write one case for each domain risk and obligation on your list that the story does not cover.
188
195
  - Each case MUST have a title, the start page when you know it, and numbered steps with an expected result, specific enough for a browser.
189
196
  - When the story is in Azure DevOps or Jira, and "This session" says that ForgeQE saves cases there, you MUST save them with save_test_cases before they run.
190
197
  - Give save_test_cases the system, the story, and each case with its steps and expected results. You MUST NOT create the cases with the MCP tools or the browser.
191
- - In the reply, you MUST list each saved case as a markdown link to its work item, with its title, and without its full steps.
198
+ - save_test_cases writes nothing for a case with status differs or similar. You MUST NOT say that such a case was saved or updated. Show how it differs from linked_case, and offer update_test_case. Use force_new only when the user says that it is a different case.
199
+ - In the reply, you MUST list each saved case as a markdown link to its work item, with its title and its status, and without its full steps.
192
200
  - When ForgeQE does not save cases there, or the user says not to, you MUST show them as a short markdown list with numbered titles, steps, and expected results.
193
201
  - You MUST then run them with run_test_cases, unless the user asked only for the cases.
194
202
  - You MUST run saved Azure DevOps cases by their ids (save_test_cases gives run_with), so that the results go back. Run other cases as text sources, one entry for each case.
195
203
  - You MUST save cases from other requirements into a test management system only when the user asks. Use save_test_cases when there is a story to link them to. Give the new ids as links.
196
204
 
205
+ ### End-to-end journeys from test cases
206
+ For "an end-to-end test for story 1234", "combine these cases into one flow", or "E2E from the test plan":
207
+ - You MUST build on the cases that exist. Call e2e_journey with the story, or with the sources. You MUST NOT write the cases again first.
208
+ - When the story has no linked cases yet, write and save them by "Testing from requirements" first, then call e2e_journey.
209
+ - Order the sections so that each one leaves the application in the state that the next one needs. A negative section goes last, or stays its own case.
210
+ - Show each journey with the cases that it covers. When the user agrees, save it with save_test_cases as one case titled "E2E: …", with a description that names the cases it covers. Keep the original cases.
211
+ - For end-to-end API scenarios, use api_scenarios.
212
+
197
213
  ### Signing in
198
214
  - The browser agent signs in to the sites under "Saved logins" with stored credentials and a saved session. You MUST NOT ask the user for those passwords or codes.
199
215
  - You MUST NOT put credentials in goals.
@@ -218,10 +234,25 @@ For a Confluence page, a Jira or Azure DevOps story, or a specification file:
218
234
  - You MUST name the files that it made: report.html in the run folder, and the test that write_test wrote. Say if the run of that test passed. When ForgeQE wrote no test, say so and why.
219
235
  - When the user wants logs or details to share for debugging, tell them to type /logs, and add shots for screenshots. Outside the shell, forgeqe logs bundle writes one archive with secrets replaced.
220
236
 
237
+ ### Regression packs
238
+ When the user asks for a regression pack, or which tests to run for a release, a sprint, or recent pull requests:
239
+ - You MUST give build_regression_pack every source the user names. Give an Azure DevOps sprint, area, epic, or work items directly. Give a commit range as from and to, with range_repo when it is the product repository. Give pull requests as prs or merged_days.
240
+ - When the changes are in another connected tool (a Jira release or fix version, GitHub issues, a release note in Confluence), you MUST read them with that tool first. Then you MUST pass each one in items with its id, title, text, url, and the test cases and work items it links to. You MUST NOT shorten their text: the words find the tests.
241
+ - You MUST report the tests by why they are in, the changes no test covers with the closest test for each, and the risk areas.
242
+ - When the user did not say where to run the pack, you MUST ask one question: on this machine, on TestMu AI, or in a pipeline. You MUST NOT run a pack until the user agrees.
243
+ - After a run, you MUST name the failing tests with their messages, and the tests that fail now but passed in the last run.
244
+
221
245
  ### Writing tests
222
246
  When the user asks, or when the setup below says that passed runs become tests:
223
247
  - You MUST call write_test with the folder of the passed run. Give the repository when it is not the working directory.
224
- - When an earlier job wrote a test for the same flow, browser_run runs that test first. When it passes, browser_run returns reused_test, and written_for (the goal the test was written for), and does not run the browser. You MUST compare written_for with the request of the user. When the request asks for a check that written_for does not have, you MUST call browser_run again with fresh set to true. Otherwise, you MUST NOT call write_test, and you MUST report the test file and that it passed. When it fails, browser_run runs the flow, and write_test updates that test. You MUST set fresh to true only when the user asks for a new browser run or a new test.
248
+ - When an earlier job wrote a test for the same flow, browser_run runs that test first. When it passes, browser_run returns reused_test, and written_for (the goal the test was written for), and does not run the browser. You MUST compare written_for with the request of the user. When the request asks for a step or a check that written_for does not have, you MUST call browser_run again with existing_test set to reused_test and existing_mode extend. Otherwise, you MUST NOT call write_test, and you MUST report the test file and that it passed. When it fails, browser_run runs the flow, and write_test updates that test. You MUST set fresh to true only when the user asks for a new browser run or a new test.
249
+ - Before a browser run for a test, browser_run searches the tests of the repository. When some are close to the request but not the same flow, it returns status existing_tests_found and does not start the browser. For each test it gives its steps, the elements of the request that it covers and lacks, and written_for when an earlier job wrote it. You MUST compare them with the request of the user before any browser run. Then you MUST call browser_run again with the same goal and one of these:
250
+ - existing_test (the test, Class.Method) and existing_mode reuse, when that test already does and checks everything the request asks.
251
+ - existing_mode extend, when the test covers the start of the journey and the request adds a few steps or checks to it.
252
+ - existing_mode sibling, when the request is a variant of the test: another path, other data, more than a few added steps, or a test that is long already. A new test goes beside it in its class and reuses its setup and steps.
253
+ - fresh set to true, when no test is this journey. The new test still reuses the page objects and helpers of the repository.
254
+ When existing_tests_found has sent_as task, you sent a check of the application as a task, and tests of the repository are close to it. When one of them does what the check asks, you MUST answer the check with it: call browser_run again with the same goal and purpose, existing_test set to that test, and existing_mode reuse. When the user asked for a test, you MUST use purpose test and one of the choices above. You MUST set fresh to true only when no test does what the check asks, or when the check needs data that a passing test does not give, such as a value to read.
255
+ You MUST NOT write a new test file for a journey that an existing test covers in part. After extend or sibling, write_test does what you chose. You MUST NOT give it a mode.
225
256
  - Cases that run_test_cases ran in a session that writes tests already have their test. You MUST call write_test only for a case that has none, when the user asks.
226
257
  - You MUST NOT write that test yourself. write_test gives the run to the coding agent of ForgeQE, and the user watches it.
227
258
  - Reuse before you rebuild, and extend before you duplicate. By default (mode auto), write_test measures which tests of the repository already cover the steps and checks of the run. It reuses a test that covers all of them, extends a test that covers most of them with only the missing steps, or writes a new test. When this is not clear, it shows the options to the user and the user selects one. You MUST NOT ask the user the same question again.
@@ -12,6 +12,8 @@ ForgeQE turns a passed run into an automated test. The test asserts the results
12
12
  - You MUST set `dynamic` to true for live data that changes on a different run, such as the date, a price, or a count. The test then asserts the rule, not this value.
13
13
  - You MUST record only the results that the task tells you to verify.
14
14
  - When the task lists expected results, you MUST record one check for each, and copy `expected` from that line.
15
+ - When the task is a test case with numbered steps, you MUST set `step` to the number of the step that the result belongs to.
16
+ - You MUST record the check of a step as soon as its expected result shows, before you start the next step.
15
17
  - ForgeQE finds the element that shows `text`. You MUST select text that points to the subject of the line:
16
18
  - For the items in a list, copy text from one item, such as the name of one result. Do not copy a summary line such as "2,463 properties found".
17
19
  - For a page or a section that opens, copy its heading or a different label that names it.
@@ -25,6 +25,8 @@ ForgeQE turns a passed run into an automated test. The test asserts the results
25
25
  - You MUST set `dynamic` to true for live data that changes on a different run, such as the date, a price, or a count. The test then asserts the rule, not this value.
26
26
  - You MUST record only the results that the task tells you to verify.
27
27
  - When the task lists expected results, you MUST record one check for each, and copy `expected` from that line.
28
+ - When the task is a test case with numbered steps, you MUST set `step` to the number of the step that the result belongs to.
29
+ - You MUST record the check of a step as soon as its expected result shows, before you start the next step.
28
30
  - ForgeQE finds the element that shows `text`. You MUST select text that points to the subject of the line:
29
31
  - For the items in a list, copy text from one item, such as the name of one result. Do not copy a summary line such as "2,463 properties found".
30
32
  - For a page or a section that opens, copy its heading or a different label that names it.
@@ -72,9 +74,10 @@ def register_checks(tools: Any, checks: List[Dict[str, Any]]) -> None:
72
74
  "Record one result the task asks you to verify, before done: expected (the result in a few words), "
73
75
  "text (the shortest exact text on the page that shows it, copied as shown), and dynamic=true when that text "
74
76
  "is live data that will differ on another run (today's date, a price, a count, today's featured item). "
75
- "holds=false when the page does not show the expected result: then text is what the page shows instead."
77
+ "holds=false when the page does not show the expected result: then text is what the page shows instead. "
78
+ "step: when the task is a test case with numbered steps, the number of the step whose expected result this is."
76
79
  )
77
- async def record_check(expected: str, text: str, browser_session: BrowserSession, dynamic: bool = False, holds: bool = True) -> ActionResult:
80
+ async def record_check(expected: str, text: str, browser_session: BrowserSession, dynamic: bool = False, holds: bool = True, step: int = 0) -> ActionResult:
78
81
  expected, text = " ".join(str(expected or "").split())[:200], " ".join(str(text or "").split())[:200]
79
82
  if not text:
80
83
  return ActionResult(error="Give the text on the page that shows the result.")
@@ -91,6 +94,9 @@ def register_checks(tools: Any, checks: List[Dict[str, Any]]) -> None:
91
94
  if not holds:
92
95
  # A failed expectation is a test result: `text` is what the page shows instead.
93
96
  check["holds"] = False
97
+ if isinstance(step, int) and step > 0:
98
+ # Which test step this checks: memory records where that step ended, to replay it after the case is edited.
99
+ check["step"] = step
94
100
  # The element showing the text and its section, with locators checked on this page, for the test's assertion.
95
101
  found = await locate_check(browser_session, text, dynamic=dynamic)
96
102
  if found:
@@ -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():
@@ -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'}. "